diff --git a/Gruntfile.js b/Gruntfile.js index ad493342436b6..82ae5ff092966 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1346,7 +1346,19 @@ module.exports = function(grunt) { 'uglify:jqueryform' ] ); + grunt.registerTask( 'build:gutenberg', 'Builds the @wordpress packages in gb-src.', function() { + var result = spawn( 'node', [ 'tools/gutenberg/build-packages.js' ], { + cwd: __dirname, + stdio: 'inherit' + } ); + + if ( result.status !== 0 ) { + grunt.fail.fatal( 'Building the gb-src packages failed.' ); + } + } ); + grunt.registerTask( 'build:js', [ + 'build:gutenberg', 'clean:js', 'webpack:prod', 'webpack:dev', diff --git a/gb-src/.browserslistrc b/gb-src/.browserslistrc new file mode 100644 index 0000000000000..0152f61ef03df --- /dev/null +++ b/gb-src/.browserslistrc @@ -0,0 +1 @@ +extends @wordpress/browserslist-config diff --git a/gb-src/.editorconfig b/gb-src/.editorconfig new file mode 100644 index 0000000000000..a541e47e767bc --- /dev/null +++ b/gb-src/.editorconfig @@ -0,0 +1,21 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[*.yml] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/gb-src/.eslintignore b/gb-src/.eslintignore new file mode 100644 index 0000000000000..aab87ed140ccd --- /dev/null +++ b/gb-src/.eslintignore @@ -0,0 +1,11 @@ +.cache +build +build-module +node_modules +packages/block-serialization-spec-parser/parser.js +packages/e2e-tests/plugins +playground/dist +vendor +wordpress + +!.eslintrc.js diff --git a/gb-src/.eslintrc.js b/gb-src/.eslintrc.js new file mode 100644 index 0000000000000..7d2e6f3d40bca --- /dev/null +++ b/gb-src/.eslintrc.js @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +const { escapeRegExp } = require( 'lodash' ); + +/** + * Internal dependencies + */ +const { version } = require( './package' ); + +/** + * Regular expression string matching a SemVer string with equal major/minor to + * the current package version. Used in identifying deprecations. + * + * @type {string} + */ +const majorMinorRegExp = escapeRegExp( version.replace( /\.\d+$/, '' ) ) + '(\\.\\d+)?'; + +module.exports = { + root: true, + extends: [ + 'plugin:@wordpress/eslint-plugin/recommended', + 'plugin:eslint-comments/recommended', + ], + plugins: [ + 'import', + ], + globals: { + wp: 'off', + }, + rules: { + '@wordpress/dependency-group': 'error', + '@wordpress/gutenberg-phase': 'error', + '@wordpress/react-no-unsafe-timeout': 'error', + 'no-restricted-syntax': [ + 'error', + // NOTE: We can't include the forward slash in our regex or + // we'll get a `SyntaxError` (Invalid regular expression: \ at end of pattern) + // here. That's why we use \\u002F in the regexes below. + { + selector: 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]', + message: 'Path access on WordPress dependencies is not allowed.', + }, + { + selector: 'ImportDeclaration[source.value=/^react-spring(?!\\u002Fweb\.cjs)/]', + message: 'The react-spring dependency must specify CommonJS bundle: react-spring/web.cjs', + }, + { + selector: 'CallExpression[callee.name="deprecated"] Property[key.name="version"][value.value=/' + majorMinorRegExp + '/]', + message: 'Deprecated functions must be removed before releasing this version.', + }, + { + selector: 'CallExpression[callee.name=/^(__|_n|_nx|_x)$/]:not([arguments.0.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + { + selector: 'CallExpression[callee.name=/^(_n|_nx|_x)$/]:not([arguments.1.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + { + selector: 'CallExpression[callee.name=_nx]:not([arguments.3.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + { + selector: 'CallExpression[callee.name=/^(__|_x|_n|_nx)$/] Literal[value=/\\.{3}/]', + message: 'Use ellipsis character (…) in place of three dots', + }, + { + selector: 'ImportDeclaration[source.value="redux"] Identifier.imported[name="combineReducers"]', + message: 'Use `combineReducers` from `@wordpress/data`', + }, + { + selector: 'ImportDeclaration[source.value="lodash"] Identifier.imported[name="memoize"]', + message: 'Use memize instead of Lodash’s memoize', + }, + { + selector: 'CallExpression[callee.object.name="page"][callee.property.name="waitFor"]', + message: 'Prefer page.waitForSelector instead.', + }, + { + selector: 'JSXAttribute[name.name="id"][value.type="Literal"]', + message: 'Do not use string literals for IDs; use withInstanceId instead.', + }, + { + // Discourage the usage of `Math.random()` as it's a code smell + // for UUID generation, for which we already have a higher-order + // component: `withInstanceId`. + selector: 'CallExpression[callee.object.name="Math"][callee.property.name="random"]', + message: 'Do not use Math.random() to generate unique IDs; use withInstanceId instead. (If you’re not generating unique IDs: ignore this message.)', + }, + { + selector: 'CallExpression[callee.name="withDispatch"] > :function > BlockStatement > :not(VariableDeclaration,ReturnStatement)', + message: 'withDispatch must return an object with consistent keys. Avoid performing logic in `mapDispatchToProps`.', + }, + { + selector: 'LogicalExpression[operator="&&"][left.property.name="length"][right.type="JSXElement"]', + message: 'Avoid truthy checks on length property rendering, as zero length is rendered verbatim.', + }, + ], + 'react/forbid-elements': [ 'error', { + forbid: [ + [ 'circle', 'Circle' ], + [ 'g', 'G' ], + [ 'path', 'Path' ], + [ 'polygon', 'Polygon' ], + [ 'rect', 'Rect' ], + [ 'svg', 'SVG' ], + ].map( ( [ element, componentName ] ) => { + return { + element, + message: `use cross-platform <${ componentName }> component instead.`, + }; + } ), + } ], + }, + overrides: [ + { + files: [ 'packages/**/*.js' ], + rules: { + 'import/no-extraneous-dependencies': 'error', + }, + excludedFiles: [ + '**/*.@(android|ios|native).js', + '**/@(benchmark|test|__tests__)/**/*.js', + ], + }, + { + files: [ + 'packages/jest*/**/*.js', + ], + extends: [ + 'plugin:@wordpress/eslint-plugin/test-unit', + ], + }, + { + files: [ 'packages/e2e-test*/**/*.js' ], + extends: [ + 'plugin:@wordpress/eslint-plugin/test-e2e', + ], + }, + ], +}; diff --git a/gb-src/.github/ISSUE_TEMPLATE/Bug_report.md b/gb-src/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000000000..20aa7016d17e9 --- /dev/null +++ b/gb-src/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +- Please add the version of Gutenberg you are using in the description. +- To report a security issue, please visit the WordPress HackerOne program: https://hackerone.com/wordpress. diff --git a/gb-src/.github/ISSUE_TEMPLATE/Custom.md b/gb-src/.github/ISSUE_TEMPLATE/Custom.md new file mode 100644 index 0000000000000..196cdeb63305f --- /dev/null +++ b/gb-src/.github/ISSUE_TEMPLATE/Custom.md @@ -0,0 +1,17 @@ +--- +name: Help Request +about: Please post help requests or ‘how to’ questions in support channels first + +--- + +Search first! Your issue may have already been reported. + +For general help requests, please post in the support forum at https://wordpress.org/support/forum/how-to-and-troubleshooting/. + +Technical help requests have their own section of the support forum at https://wordpress.org/support/forum/wp-advanced/. + +You may also ask for technical support at https://wordpress.stackexchange.com/. + +Please make sure you have checked the Handbook at https://wordpress.org/gutenberg/handbook before asking your question. + +Thank you! diff --git a/gb-src/.github/ISSUE_TEMPLATE/Feature_request.md b/gb-src/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000000000..7f4075fd55fc5 --- /dev/null +++ b/gb-src/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. diff --git a/gb-src/.github/PULL_REQUEST_TEMPLATE.md b/gb-src/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..bef89730704cb --- /dev/null +++ b/gb-src/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Description + + +## How has this been tested? + + + + +## Screenshots + +## Types of changes + + + + + +## Checklist: +- [ ] My code is tested. +- [ ] My code follows the WordPress code style. +- [ ] My code follows the accessibility standards. +- [ ] My code has proper inline documentation. +- [ ] I've included developer documentation if appropriate. diff --git a/gb-src/.github/workflows/pull-request-automation.yml b/gb-src/.github/workflows/pull-request-automation.yml new file mode 100644 index 0000000000000..10277d5a3b45e --- /dev/null +++ b/gb-src/.github/workflows/pull-request-automation.yml @@ -0,0 +1,14 @@ +on: pull_request +name: Pull request automation + +jobs: + pull-request-automation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + # Changing into the action's directory and running `npm install` is much + # faster than a full project-wide `npm ci`. + - run: cd packages/project-management-automation && npm install + - uses: ./packages/project-management-automation + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/gb-src/.jshintignore b/gb-src/.jshintignore new file mode 100644 index 0000000000000..2cb7d2a2e90e8 --- /dev/null +++ b/gb-src/.jshintignore @@ -0,0 +1 @@ +**/*.js diff --git a/gb-src/.npmrc b/gb-src/.npmrc new file mode 100644 index 0000000000000..1dab4ed4c3020 --- /dev/null +++ b/gb-src/.npmrc @@ -0,0 +1 @@ +save-exact = true diff --git a/gb-src/.nvmrc b/gb-src/.nvmrc new file mode 100644 index 0000000000000..f599e28b8ab0d --- /dev/null +++ b/gb-src/.nvmrc @@ -0,0 +1 @@ +10 diff --git a/gb-src/.stylelintrc.json b/gb-src/.stylelintrc.json new file mode 100644 index 0000000000000..648e4d1113e4a --- /dev/null +++ b/gb-src/.stylelintrc.json @@ -0,0 +1,16 @@ +{ + "extends": "stylelint-config-wordpress", + "rules": { + "at-rule-empty-line-before": null, + "at-rule-no-unknown": null, + "comment-empty-line-before": null, + "declaration-property-unit-whitelist": null, + "font-weight-notation": null, + "max-line-length": null, + "no-descending-specificity": null, + "no-duplicate-selectors": null, + "rule-empty-line-before": null, + "selector-class-pattern": null, + "value-keyword-case": null + } +} diff --git a/gb-src/.travis.yml b/gb-src/.travis.yml new file mode 100644 index 0000000000000..563357347acd0 --- /dev/null +++ b/gb-src/.travis.yml @@ -0,0 +1,212 @@ +dist: trusty + +language: generic + +services: + - docker + +notifications: + email: + on_success: never + on_failure: change + +cache: + directories: + - $HOME/.composer/cache + - $HOME/.jest-cache + - $HOME/.npm + - $HOME/.nvm/.cache + +branches: + only: + - master + - rnmobile/master + - /wp\/.*/ + +env: + global: + - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true + - WP_DEVELOP_DIR: ./wordpress + - LOCAL_SCRIPT_DEBUG: false + - INSTALL_COMPOSER: false + - INSTALL_WORDPRESS: true + +before_install: + - nvm install --latest-npm + - | + if [[ "$INSTALL_WORDPRESS" = "true" ]]; then + # Upgrade docker-compose. + sudo rm /usr/local/bin/docker-compose + curl -sL https://github.com/docker/compose/releases/download/1.24.0/docker-compose-`uname -s`-`uname -m` > docker-compose + chmod +x docker-compose + sudo mv docker-compose /usr/local/bin + fi + +install: + # Build Gutenberg. + - npm ci + - npm run build + - | + if [[ "$INSTALL_WORDPRESS" = "true" ]]; then + # Download and unpack WordPress. + curl -sL https://wordpress.org/nightly-builds/wordpress-latest.zip -o /tmp/wordpress-latest.zip + unzip -q /tmp/wordpress-latest.zip -d /tmp + mkdir -p wordpress/src + mv /tmp/wordpress/* wordpress/src + + # Create the upload directory with permissions that Travis can handle. + mkdir -p wordpress/src/wp-content/uploads + chmod 767 wordpress/src/wp-content/uploads + + # Grab the tools we need for WordPress' local-env. + curl -sL https://github.com/WordPress/wordpress-develop/archive/master.zip -o /tmp/wordpress-develop.zip + unzip -q /tmp/wordpress-develop.zip -d /tmp + mv \ + /tmp/wordpress-develop-master/tools \ + /tmp/wordpress-develop-master/tests \ + /tmp/wordpress-develop-master/.env \ + /tmp/wordpress-develop-master/docker-compose.yml \ + /tmp/wordpress-develop-master/wp-cli.yml \ + /tmp/wordpress-develop-master/*config-sample.php \ + /tmp/wordpress-develop-master/package.json wordpress + + # Install WordPress. + cd wordpress + npm install dotenv wait-on + npm run env:start + sleep 10 + npm run env:install + cd .. + + # Connect Gutenberg to WordPress. + npm run env connect + npm run env cli plugin activate gutenberg + fi + - | + if [[ "$INSTALL_COMPOSER" = "true" ]]; then + npm run env docker-run -- php composer install + fi + - | + if [[ "$E2E_ROLE" = "author" ]]; then + npm run env cli -- user create author author@example.com --role=author --user_pass=authpass + npm run env cli -- post update 1 --post_author=2 + fi + +jobs: + include: + - name: Lint + install: + - npm ci + script: + - npm run lint + + - name: Build artifacts + install: + # A "full" install is executed, since `npm ci` does not always exit + # with an error status code if the lock file is inaccurate. + # + # See: https://github.com/WordPress/gutenberg/issues/16157 + - npm install + script: + - npm run check-local-changes + + - name: License compatibility + install: + - npm ci + script: + - npm run check-licenses + + - name: JavaScript unit tests + env: INSTALL_WORDPRESS=false + install: + - npm ci + # It's not necessary to run the full build, since Jest can interpret + # source files with `babel-jest`. Some packages have their own custom + # build tasks, however. These must be run. + - npx lerna run build + script: + - npm run test-unit -- --ci --maxWorkers=2 --cacheDirectory="$HOME/.jest-cache" + + - name: JavaScript native mobile tests + install: + - npm ci + # It's not necessary to run the full build, since Jest can interpret + # source files with `babel-jest`. Some packages have their own custom + # build tasks, however. These must be run. + - npx lerna run build + script: + - npm run test-unit:native -- --ci --maxWorkers=2 --cacheDirectory="$HOME/.jest-cache" + + - name: PHP unit tests + env: INSTALL_COMPOSER=true + script: + - npm run test-php && npm run test-unit-php-multisite + + - name: PHP unit tests (PHP 5.6) + env: INSTALL_COMPOSER=true LOCAL_PHP=5.6-fpm + script: + - npm run test-php && npm run test-unit-php-multisite + + - name: E2E tests (Admin) (1/4) + env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 0' < ~/.jest-e2e-tests ) + + - name: E2E tests (Admin) (2/4) + env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 1' < ~/.jest-e2e-tests ) + + - name: E2E tests (Admin) (3/4) + env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 2' < ~/.jest-e2e-tests ) + + - name: E2E tests (Admin) (4/4) + env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 3' < ~/.jest-e2e-tests ) + + - name: E2E tests (Author) (1/4) + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 0' < ~/.jest-e2e-tests ) + + - name: E2E tests (Author) (2/4) + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 1' < ~/.jest-e2e-tests ) + + - name: E2E tests (Author) (3/4) + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 2' < ~/.jest-e2e-tests ) + + - name: E2E tests (Author) (4/4) + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 3' < ~/.jest-e2e-tests ) + + allow_failures: + - name: JavaScript native mobile tests + +before_deploy: + - npm install + - npm run playground:build -- --public-url '/gutenberg' + +deploy: + provider: pages + skip_cleanup: true + github_token: $GITHUB_TOKEN + keep_history: true + local_dir: playground/dist + on: + branch: master diff --git a/gb-src/CODE_OF_CONDUCT.md b/gb-src/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..5a77323cc3c69 --- /dev/null +++ b/gb-src/CODE_OF_CONDUCT.md @@ -0,0 +1,11 @@ +This project comes under the WordPress [Etiquette](https://wordpress.org/about/etiquette/): + +In the WordPress open source project, we realize that our biggest asset is the community that we foster. The project, as a whole, follows these basic philosophical principles from The Cathedral and The Bazaar. + +- Contributions to the WordPress open source project are for the benefit of the WordPress community as a whole, not specific businesses or individuals. All actions taken as a contributor should be made with the best interests of the community in mind. +- Participation in the WordPress open source project is open to all who wish to join, regardless of ability, skill, financial status, or any other criteria. +- The WordPress open source project is a volunteer-run community. Even in cases where contributors are sponsored by companies, that time is donated for the benefit of the entire open source community. +- Any member of the community can donate their time and contribute to the project in any form including design, code, documentation, community building, etc. For more information, go to make.wordpress.org. +- The WordPress open source community cares about diversity. We strive to maintain a welcoming environment where everyone can feel included, by keeping communication free of discrimination, incitement to violence, promotion of hate, and unwelcoming behavior. + +The team involved will block any user who causes any breach in this. diff --git a/gb-src/CONTRIBUTING.md b/gb-src/CONTRIBUTING.md new file mode 100644 index 0000000000000..8ec7046c01f84 --- /dev/null +++ b/gb-src/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +Thank you for thinking about contributing to WordPress' Gutenberg project! If you're unsure of anything, know that you're 💯 welcome to submit an issue or pull request on any topic. The worst that can happen is that you'll be politely directed to the best location to ask your question or to change something in your pull request. We appreciate any sort of contribution and don't want a wall of rules to get in the way of that. + +As with all WordPress projects, we want to ensure a welcoming environment for everyone. With that in mind, all contributors are expected to follow our [Code of Conduct](/CODE_OF_CONDUCT.md). + +Before contributing, we encourage you to review the [Contributor Handbook](https://developer.wordpress.org/block-editor/contributors/). If you have any questions, please ask, either in Slack or open an issue in GitHub so we can help clarify. + +All WordPress projects are [licensed under the GPLv2+](/LICENSE.md), and all contributions to Gutenberg will be released under the GPLv2+ license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv2+ license. + +This document covers the technical details around setup, and submitting your contribution to the Gutenberg project. + +## Developer Contributions + +Please see the [Developer Contributions section](/docs/contributors/develop.md) of the Contributor Handbook. + +## How Can Designers Contribute? + +If you'd like to contribute to the design or front-end, feel free to contribute to tickets labelled [Needs Design](https://github.com/WordPress/gutenberg/issues?q=is%3Aissue+is%3Aopen+label%3A%22Needs+Design%22) or [Needs Design Feedback](https://github.com/WordPress/gutenberg/issues?q=is%3Aissue+is%3Aopen+label%3A"Needs+Design+Feedback%22). We could use your thoughtful replies, mockups, animatics, sketches, doodles. Proposed changes are best done as minimal and specific iterations on the work that precedes it so we can compare. The [WordPress Design team](http://make.wordpress.org/design/) uses [Figma](https://www.figma.com/) to collaborate and share work. If you'd like to contribute, join the [#design channel](http://wordpress.slack.com/messages/design/) in [Slack](https://make.wordpress.org/chat/) and ask the team to set you up with a free Figma account. This will give you access to a helpful [library of components](https://www.figma.com/file/ZtN5xslEVYgzU7Dd5CxgGZwq/WordPress-Components?node-id=0%3A1) used in WordPress. + +## Triage Contributions + +*Triage* is the practice of reviewing existing issues to make sure they’re relevant, actionable, and have all the information needed to reproduce and/or solve the issue. Triaging is a very important contribution because it allows the community to focus on and prioritise issues, feature proposals, discussions, and so on. + +If you want to learn more about triage, and why it it important, please see the [repository management section](docs/contributors/repository-management.md#triaging-issues) of the Contributor Handbook. + +## Contribute to the Documentation + +Please see the [Documentation section](/docs/contributors/document.md) of the Contributor Handbook. + +Documentation is automatically synced from `master` to the [Block Editor Handbook](https://developer.wordpress.org/block-editor/) every 15 minutes. + +### `@wordpress/component` + +If you're contributing to the documentation of any component from the `@wordpress/component` package, take a look at its [guidelines for contributing](/packages/components/CONTRIBUTING.md). + +## Reporting Security Issues + +Please see [SECURITY.md](/SECURITY.md). diff --git a/gb-src/CONTRIBUTORS.md b/gb-src/CONTRIBUTORS.md new file mode 100644 index 0000000000000..55b2954142873 --- /dev/null +++ b/gb-src/CONTRIBUTORS.md @@ -0,0 +1,144 @@ +# Contributors + +Gutenberg is built by many contributors and volunteers. Thanks to all of them for their work! + +This list is manually curated to include valuable contributions by volunteers that do not include code, such as user testing, providing feedback, or mockups. Please edit this list to include new contributors as they come in. There is no particular order to this list. If you or someone else was omitted from this list, we assure you that was not intentional. Please let us know and we'll add you. For volunteers who contributed their translations, your names are listed on [WordPress Translate site here](https://translate.wordpress.org/projects/wp-plugins/gutenberg/contributors). + +| GitHub Username | WordPress.org Username| +| --------------- | --------------------- | +| @youknowriad | @youknowriad | +| @aduth | @aduth | +| @jasmussen | @joen | +| @iseulde | @iseulde | +| @mtias | @mtias | +| @nylen | @jnylen0 | +| @EphoxJames | | +| @mkaz | @mkaz | +| @notnownikki | @notnownikki | +| @BE-Webdesign | @chopinbach | +| @njpanderson | | +| @mimo84 | | +| @intronic | | +| @westonruter | @westonruter | +| @mcsf | @mcsf | +| @dmsnell | @dmsnell | +| @afercia | @afercia | +| @paulwilde | @paulwilde | +| @mitogh | @mitogh | +| @codebykat | @codebykat | +| @ahmadawais | @mrahmadawais | +| @kopepasah | @kopepasah | +| @circlecube | @circlecube | +| @adamsilverstein | @adamsilverstein | +| @timmyc | @timmydcrawford | +| @ephox-mogran | | +| @nb | @nbachiyski | +| @JDGrimes | @JDGrimes | +| @Soean | @Soean | +| @mapk | @mapk | +| @sirjonathan | @sirjonathan | +| @j-falk | @j-falk | +| @ryelle | @ryelle | +| @ntwb | @netweb | +| @lamosty | @lamosty | +| @willybahuaud | @willybahuaud | +| @maurobringolf | @maurobringolf | +| @aaronjorbin | @jorbin | +| @spocke | @spocke | +| @androb | @androb | +| @annaephox | @annaharrison | +| @Afraithe | | +| @georgeh | | +| @m | @matt | +| @melchoyce | @melchoyce | +| @pento | @pento | +| @karmatosed | @karmatosed | +| @nitrajka | @nitrajka | +| @sirreal | @jonsurrell | +| @inhil | | +| @georgeolaru | @babbardel | +| @martinlugton | @martinlugton | +| @joyously | @joyously | +| @rileybrook | @rileybrook | +| @azaozz | @azaozz | +| @folletto | @folletto | +| @ianstewart | @iandstewart | +| @johnpixle | @johnpixle | +| @mrwweb | @mrwweb | +| @diegoliv | @diegoliv | +| @lukecav | @lukecavanagh | +| @shaunandrews | @shaunandrews | +| @hugobaeta | @hugobaeta | +| @mizejewski | @mizejewski | +| @buzztone | @buzztone | +| @mathetos | @webdevmattcrom | +| @GaryJones | @garyj | +| @jasonagnew | | +| @brickbones | @ieatwebsites | +| @iamgabrielma | @gma992 | +| @swissspidy | @swissspidy | +| @dixitadusara | | +| @ameeker | @ameeker | +| @StaggerLeee | @stagger-lee | +| @jblz | @jblz | +| @nic-bertino | @nicbertino | +| @rahmon | @rahmohn | +| @vladanost | | +| @gziolo | @gziolo | +| @lancewillett | @lancewillett | +| @lynneux | @lynneux | +| @betsela | @betsela | +| @fuyuko | @fuyuko | +| | @msdesign21 | +| @thrijith | @thrijith | +| @Cloud887 | | +| @hblackett | @hblackett | +| @vishalkakadiya | @vishalkakadiya | +| @c-shultz | | +| @nfmohit-wpmudev | @nfmohit | +| @noisysocks | @noisysocks | +| @omarreiss | @omarreiss | +| @hedgefield | @hedgefield | +| @hideokamoto | @hideokamoto | +| @mirucon | @mirucon | +| @nosolosw | @nosolosw | +| @DannyCooper | @DannyCooper | +| @burhandodhy | @burhandodhy | +| @ZebulanStanphill | @zebulan | +| @BenjaminZekavica | @benjamin_zekavica | +| @danielbachhuber | @danielbachhuber | +| @jorgefilipecosta | @jorgefilipecosta | +| @ajitbohra | @ajitbohra | +| @ChrisVanPatten | @chrisvanpatten | +| @mayukojpn | @mayukojpn | +| @tofumatt | @lonelyvegan | +| @LukePettway | @luke_pettway | +| @pratikthink | @pratikthink | +| @amdrew | @sumobi | +| @MaedahBatool | @MaedahBatool | +| @luehrsen | @luehrsen | +| @getsource | @mikeschroder | +| @greatislander | @greatislander | +| @sharazghouri | @sharaz | +| @jakeparis | @jakeparis | +| @designsimply | @designsimply | +| @aldavigdis | @aldavigdis | +| @miya0001 | @miyauchi | +| @naogify | @naoki0h | +| @gutendev | @gutendev | +| @drdogbot7 | @drdogbot7 | +| @m-e-h | @m-e-h | +| @melchoyce | @melchoyce | +| @sarahmonster | @tinkerbelly | +| @kjellr | @kjellr | +| @etoledom | | +| @hypest | | +| @koke | | +| @pinarol | | +| @daniloercoli | | +| @marecar3 | | +| @Tug | | +| @diegoreymendez | | +| @mkevins | | +| @SergioEstevao | | +| @mzorz | @mzorz | diff --git a/gb-src/LICENSE.md b/gb-src/LICENSE.md new file mode 100644 index 0000000000000..7918e383b331d --- /dev/null +++ b/gb-src/LICENSE.md @@ -0,0 +1,400 @@ +### WordPress - Web publishing software + + Copyright 2011-2019 by the contributors + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +This program incorporates work covered by the following copyright and +permission notices: + + b2 is (c) 2001, 2002 Michel Valdrighi - m@tidakada.com - + http://tidakada.com + + Wherever third party code has been used, credit has been given in the code's + comments. + + b2 is released under the GPL + +and + + WordPress - Web publishing software + + Copyright 2003-2010 by the contributors + + WordPress is released under the GPL + +--- + +### GNU GENERAL PUBLIC LICENSE + +Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +### Preamble + +The licenses for most software are designed to take away your freedom +to share and change it. By contrast, the GNU General Public License is +intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + +To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if +you distribute copies of the software, or if you modify it. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + +We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + +Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, +we want its recipients to know that what they have is not the +original, so that any problems introduced by others will not reflect +on the original authors' reputations. + +Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at +all. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +**0.** This License applies to any program or other work which +contains a notice placed by the copyright holder saying it may be +distributed under the terms of this General Public License. The +"Program", below, refers to any such program or work, and a "work +based on the Program" means either the Program or any derivative work +under copyright law: that is to say, a work containing the Program or +a portion of it, either verbatim or with modifications and/or +translated into another language. (Hereinafter, translation is +included without limitation in the term "modification".) Each licensee +is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the Program +(independent of having been made by running the Program). Whether that +is true depends on what the Program does. + +**1.** You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a +fee. + +**2.** You may modify your copy or copies of the Program or any +portion of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + +**a)** You must cause the modified files to carry prominent notices +stating that you changed the files and the date of any change. + + +**b)** You must cause any work that you distribute or publish, that in +whole or in part contains or is derived from the Program or any part +thereof, to be licensed as a whole at no charge to all third parties +under the terms of this License. + + +**c)** If the modified program normally reads commands interactively +when run, you must cause it, when started running for such interactive +use in the most ordinary way, to print or display an announcement +including an appropriate copyright notice and a notice that there is +no warranty (or else, saying that you provide a warranty) and that +users may redistribute the program under these conditions, and telling +the user how to view a copy of this License. (Exception: if the +Program itself is interactive but does not normally print such an +announcement, your work based on the Program is not required to print +an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + +**3.** You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + +**a)** Accompany it with the complete corresponding machine-readable +source code, which must be distributed under the terms of Sections 1 +and 2 above on a medium customarily used for software interchange; or, + + +**b)** Accompany it with a written offer, valid for at least three +years, to give any third party, for a charge no more than your cost of +physically performing source distribution, a complete machine-readable +copy of the corresponding source code, to be distributed under the +terms of Sections 1 and 2 above on a medium customarily used for +software interchange; or, + + +**c)** Accompany it with the information you received as to the offer +to distribute corresponding source code. (This alternative is allowed +only for noncommercial distribution and only if you received the +program in object code or executable form with such an offer, in +accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + +**4.** You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt otherwise +to copy, modify, sublicense or distribute the Program is void, and +will automatically terminate your rights under this License. However, +parties who have received copies, or rights, from you under this +License will not have their licenses terminated so long as such +parties remain in full compliance. + +**5.** You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +**6.** Each time you redistribute the Program (or any work based on +the Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + +**7.** If, as a consequence of a court judgment or allegation of +patent infringement or for any other reason (not limited to patent +issues), conditions are imposed on you (whether by court order, +agreement or otherwise) that contradict the conditions of this +License, they do not excuse you from the conditions of this License. +If you cannot distribute so as to satisfy simultaneously your +obligations under this License and any other pertinent obligations, +then as a consequence you may not distribute the Program at all. For +example, if a patent license would not permit royalty-free +redistribution of the Program by all those who receive copies directly +or indirectly through you, then the only way you could satisfy both it +and this License would be to refrain entirely from distribution of the +Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + +**8.** If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + +**9.** The Free Software Foundation may publish revised and/or new +versions of the General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Program does not specify a +version number of this License, you may choose any version ever +published by the Free Software Foundation. + +**10.** If you wish to incorporate parts of the Program into other +free programs whose distribution conditions are different, write to +the author to ask for permission. For software which is copyrighted by +the Free Software Foundation, write to the Free Software Foundation; +we sometimes make exceptions for this. Our decision will be guided by +the two goals of preserving the free status of all derivatives of our +free software and of promoting the sharing and reuse of software +generally. + +**NO WARRANTY** + +**11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +**12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + +### END OF TERMS AND CONDITIONS + +### How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + one line to give the program's name and an idea of what it does. + Copyright (C) yyyy name of author + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +Also add information on how to contact you by electronic and paper +mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details + type `show w'. This is free software, and you are welcome + to redistribute it under certain conditions; type `show c' + for details. + +The hypothetical commands \`show w' and \`show c' should show the +appropriate parts of the General Public License. Of course, the +commands you use may be called something other than \`show w' and +\`show c'; they could even be mouse-clicks or menu items--whatever +suits your program. + +You should also get your employer (if you work as a programmer) or +your school, if any, to sign a "copyright disclaimer" for the program, +if necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright + interest in the program `Gnomovision' + (which makes passes at compilers) written + by James Hacker. + + signature of Ty Coon, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, +you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +[GNU Lesser General Public +License](http://www.gnu.org/licenses/lgpl.html) instead of this +License. diff --git a/gb-src/README.md b/gb-src/README.md new file mode 100644 index 0000000000000..494b9ba91e7be --- /dev/null +++ b/gb-src/README.md @@ -0,0 +1,79 @@ +# Gutenberg +[![Build Status](https://img.shields.io/travis/com/WordPress/gutenberg/master.svg)](https://travis-ci.com/WordPress/gutenberg) +[![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lernajs.io/) + +![Screenshot of the Gutenberg Editor, editing a post in WordPress](https://cldup.com/H0oKBfpidk.png) + +This repo is the development hub for the editor focus in WordPress Core. `Gutenberg` is the project name. + +## Getting started +- **Download:** If you want to use the latest release with your WordPress site, download the latest release from the WordPress.org plugins repository. +- **Discuss:** Conversations and discussions take place in `#core-editor` channel on the Making WordPress Slack. +- **Contribute:** Development of Gutenberg happens in this GitHub repo. Get started by reading the contributing guidelines. +- **Learn:** Discover more about the project on WordPress.org. + +**Gutenberg is more than an editor.** While the project is currently focused on building the new editor for WordPress, it doesn't end there. This lays the groundwork for a new model for WordPress Core that will ultimately impact the entire publishing experience of the platform. + +## Editing focus + +> *The editor will create a new page- and post-building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.* +> +> — Matt Mullenweg + +One thing that sets WordPress apart is that it allows you to create a post layout that's as rich as you can imagine—but only if you can build your own custom theme with HTML and CSS. By thinking of the editor as a tool that allows you to write rich posts **and** create beautiful layouts, we can transform WordPress into something users _love_, as opposed to something they choose because it happens to be what everyone else uses. + +**Gutenberg is a new way forward.** It looks at the editor as more than a content field, revisiting a layout that has been largely unchanged for almost a decade. This project allows The WordPress Project to holistically design a modern editing experience and build a foundation for things to come. + +Here's why we're looking at the whole editing screen, as opposed to just the content field: + +1. **The block unifies multiple interfaces.** If Gutenberg added blocks on top of the existing interface, it would _add_ complexity, as opposed to removing it. +2. **Simplified (and enhanced) editing.** By revisiting the interface, Gutenberg can modernize the writing, editing, and publishing experience, with usability and simplicity in mind, benefitting both new and casual users. +3. **Better interface usability.** When singular block interface takes center stage, it demonstrates a clear path forward for developers to create premium blocks, superior to both shortcodes and widgets. +4. **A fresh look at content creation.** Considering the whole interface lays a solid foundation for the next focus: full site customization. +5. **Modern tooling.** Looking at the full editor screen also gives WordPress the opportunity to drastically modernize the foundation, and take steps towards a more fluid and JavaScript-powered future that fully leverages the WordPress REST API. + +![Writing in Gutenberg 1.6](https://make.wordpress.org/core/files/2017/10/gutenberg-typing-1_6.gif) + +## Blocks + +Blocks are the unifying evolution of what is now covered, in different ways, by shortcodes, embeds, widgets, post formats, custom post types, theme options, meta-boxes, and other formatting elements. They embrace the breadth of functionality WordPress is capable of, with the clarity of a consistent user experience. + +Imagine a custom `employee` block that a client can drag onto an `About` page to automatically display a picture, name, and bio of all the employees. Imagine a whole universe of plugins just as flexible, all extending WordPress in the same way. Imagine simplified menus and widgets. Users who can instantly understand and use WordPress—and 90% of plugins. This will allow you to easily compose beautiful posts like this example. + +Check out the FAQ for answers to the most common questions about the project. + +## Compatibility + +Posts are backward compatible, and shortcodes will still work. We are continuously exploring how highly-tailored meta boxes can be accommodated, and are looking at solutions ranging from a plugin to disable Gutenberg to automatically detecting whether to load Gutenberg or not. While we want to make sure the new editing experience from writing to publishing is user-friendly, we’re committed to finding a good solution for highly-tailored existing sites. + +## The stages of Gutenberg + +Gutenberg has three planned stages. +1) **The first, aimed for inclusion in WordPress 5.0, focuses on the post editing experience** and the implementation of blocks. This initial phase focuses on a content-first approach. The use of blocks, as detailed above, allows you to focus on how your content will look without the distraction of other configuration options. This ultimately will help all users present their content in a way that is engaging, direct, and visual. These foundational elements will pave the way forward. +2) Planned for 2019, **The second stage focuses on overhauling The Customizer** and page templates. +3) Ultimately, **full site customization** will be possible. + +**Gutenberg is a big change.** There will be ways to ensure that existing functionality (like shortcodes and meta-boxes) continue to work while allowing developers the time and paths to transition effectively. Ultimately, it will open new opportunities for plugin and theme developers to better serve users through a more engaging and visual experience that takes advantage of a toolset supported by core. + +## Get involved + +We’re calling this editor project "Gutenberg" because it's a big undertaking. We are working on it every day in GitHub, and we'd love your help building it. You’re also welcome to give feedback, the easiest is to join us in our Slack channel, `#core-editor`. A weekly meeting is held in the Slack channel on Wednesdays at 13:00 UTC. + +## Contributors + +Gutenberg is built by many contributors and volunteers. Please see the full list in CONTRIBUTORS.md. + +## How You Can Contribute + +Please see CONTRIBUTING.md. + +## Further Reading + +- Gutenberg, or the Ship of Theseus, with examples of what Gutenberg might do in the future +- Editor Technical Overview +- Design Principles and block design best practices +- WP Post Grammar Parser +- Development updates on make.wordpress.org +- Documentation: Creating Blocks, Reference, and Guidelines + +

Code is Poetry.

diff --git a/gb-src/SECURITY.md b/gb-src/SECURITY.md new file mode 100644 index 0000000000000..ed78e7eeb2cde --- /dev/null +++ b/gb-src/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting Security Issues + +The Gutenberg team and WordPress community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program. diff --git a/gb-src/assets/stylesheets/_animations.scss b/gb-src/assets/stylesheets/_animations.scss new file mode 100644 index 0000000000000..b5e6655e660cb --- /dev/null +++ b/gb-src/assets/stylesheets/_animations.scss @@ -0,0 +1,5 @@ +@mixin edit-post__fade-in-animation($speed: 0.2s, $delay: 0s) { + animation: edit-post__fade-in-animation $speed ease-out $delay; + animation-fill-mode: forwards; + @include reduce-motion("animation"); +} diff --git a/gb-src/assets/stylesheets/_breakpoints.scss b/gb-src/assets/stylesheets/_breakpoints.scss new file mode 100644 index 0000000000000..3dd164c99753a --- /dev/null +++ b/gb-src/assets/stylesheets/_breakpoints.scss @@ -0,0 +1,41 @@ +/** + * Breakpoints & Media Queries + */ + +// Most used breakpoints +$break-huge: 1440px; +$break-wide: 1280px; +$break-xlarge: 1080px; +$break-large: 960px; // admin sidebar auto folds +$break-medium: 782px; // adminbar goes big +$break-small: 600px; +$break-mobile: 480px; +$break-zoomed-in: 280px; + +// All media queries currently in WordPress: +// +// min-width: 2000px +// min-width: 1680px +// min-width: 1250px +// max-width: 1120px * +// max-width: 1000px +// min-width: 769px and max-width: 1000px +// max-width: 960px * +// max-width: 900px +// max-width: 850px +// min-width: 800px and max-width: 1499px +// max-width: 800px +// max-width: 799px +// max-width: 782px * +// max-width: 768px +// max-width: 640px * +// max-width: 600px * +// max-width: 520px +// max-width: 500px +// max-width: 480px * +// max-width: 400px * +// max-width: 380px +// max-width: 320px * +// +// Those marked * seem to be more commonly used than the others. +// Let's try and use as few of these as possible, and be mindful about adding new ones, so we don't make the situation worse diff --git a/gb-src/assets/stylesheets/_colors.scss b/gb-src/assets/stylesheets/_colors.scss new file mode 100644 index 0000000000000..cafa84fba44ca --- /dev/null +++ b/gb-src/assets/stylesheets/_colors.scss @@ -0,0 +1,90 @@ +/** + * Colors + */ + +// Hugo's new WordPress shades of gray, from http://codepen.io/hugobaeta/pen/grJjVp. +$black: #000; +$dark-gray-900: #191e23; +$dark-gray-800: #23282d; +$dark-gray-700: #32373c; +$dark-gray-600: #40464d; +$dark-gray-500: #555d66; // Use this most of the time for dark items. +$dark-gray-400: #606a73; +$dark-gray-300: #6c7781; // Lightest gray that can be used for AA text contrast. +$dark-gray-200: #7e8993; +$dark-gray-150: #8d96a0; // Lightest gray that can be used for AA non-text contrast. +$dark-gray-100: #8f98a1; +$light-gray-900: #a2aab2; +$light-gray-800: #b5bcc2; +$light-gray-700: #ccd0d4; +$light-gray-600: #d7dade; +$light-gray-500: #e2e4e7; // Good for "grayed" items and borders. +$light-gray-400: #e8eaeb; // Good for "readonly" input fields and special text selection. +$light-gray-300: #edeff0; +$light-gray-200: #f3f4f5; +$light-gray-100: #f8f9f9; +$white: #fff; + +// Dark opacities, for use with light themes. +$dark-opacity-900: rgba(#000510, 0.9); +$dark-opacity-800: rgba(#00000a, 0.85); +$dark-opacity-700: rgba(#06060b, 0.8); +$dark-opacity-600: rgba(#000913, 0.75); +$dark-opacity-500: rgba(#0a1829, 0.7); +$dark-opacity-400: rgba(#0a1829, 0.65); +$dark-opacity-300: rgba(#0e1c2e, 0.62); +$dark-opacity-200: rgba(#162435, 0.55); +$dark-opacity-100: rgba(#223443, 0.5); +$dark-opacity-light-900: rgba(#304455, 0.45); +$dark-opacity-light-800: rgba(#425863, 0.4); +$dark-opacity-light-700: rgba(#667886, 0.35); +$dark-opacity-light-600: rgba(#7b86a2, 0.3); +$dark-opacity-light-500: rgba(#9197a2, 0.25); +$dark-opacity-light-400: rgba(#95959c, 0.2); +$dark-opacity-light-300: rgba(#829493, 0.15); +$dark-opacity-light-200: rgba(#8b8b96, 0.1); +$dark-opacity-light-100: rgba(#747474, 0.05); +$dark-opacity-background-fill: rgba($dark-gray-700, 0.7); // Similar to $dark-opacity-light-200, but more opaque. + +// Light opacities, for use with dark themes. +$light-opacity-900: rgba($white, 1); +$light-opacity-800: rgba($white, 0.9); +$light-opacity-700: rgba($white, 0.85); +$light-opacity-600: rgba($white, 0.8); +$light-opacity-500: rgba($white, 0.75); +$light-opacity-400: rgba($white, 0.7); +$light-opacity-300: rgba($white, 0.65); +$light-opacity-200: rgba($white, 0.6); +$light-opacity-100: rgba($white, 0.55); +$light-opacity-light-900: rgba($white, 0.5); +$light-opacity-light-800: rgba($white, 0.45); +$light-opacity-light-700: rgba($white, 0.4); +$light-opacity-light-600: rgba($white, 0.35); +$light-opacity-light-500: rgba($white, 0.3); +$light-opacity-light-400: rgba($white, 0.25); +$light-opacity-light-300: rgba($white, 0.2); +$light-opacity-light-200: rgba($white, 0.15); +$light-opacity-light-100: rgba($white, 0.1); +$light-opacity-background-fill: rgba($light-gray-300, 0.8); // Similar to $light-opacity-light-200, but more opaque. + +// Additional colors. +// Some are from https://make.wordpress.org/design/handbook/foundations/colors/. +$blue-wordpress-700: #00669b; +$blue-dark-900: #0071a1; + +$blue-medium-900: #006589; +$blue-medium-800: #00739c; +$blue-medium-700: #007fac; +$blue-medium-600: #008dbe; +$blue-medium-500: #00a0d2; +$blue-medium-400: #33b3db; +$blue-medium-300: #66c6e4; +$blue-medium-200: #bfe7f3; +$blue-medium-100: #e5f5fa; +$blue-medium-highlight: #b3e7fe; +$blue-medium-focus: #007cba; + +// Alert colors. +$alert-yellow: #f0b849; +$alert-red: #d94f4f; +$alert-green: #4ab866; diff --git a/gb-src/assets/stylesheets/_mixins.scss b/gb-src/assets/stylesheets/_mixins.scss new file mode 100644 index 0000000000000..fda485a779275 --- /dev/null +++ b/gb-src/assets/stylesheets/_mixins.scss @@ -0,0 +1,612 @@ +/** + * Breakpoint mixins + */ + +@mixin break-huge() { + @media (min-width: #{ ($break-huge) }) { + @content; + } +} + +@mixin break-wide() { + @media (min-width: #{ ($break-wide) }) { + @content; + } +} + +@mixin break-xlarge() { + @media (min-width: #{ ($break-xlarge) }) { + @content; + } +} + +@mixin break-large() { + @media (min-width: #{ ($break-large) }) { + @content; + } +} + +@mixin break-medium() { + @media (min-width: #{ ($break-medium) }) { + @content; + } +} + +@mixin break-small() { + @media (min-width: #{ ($break-small) }) { + @content; + } +} + +@mixin break-mobile() { + @media (min-width: #{ ($break-mobile) }) { + @content; + } +} + +@mixin break-zoomed-in() { + @media (min-width: #{ ($break-zoomed-in) }) { + @content; + } +} + + +/** + * Long content fade mixin + * + * Creates a fading overlay to signify that the content is longer + * than the space allows. + */ + +@mixin long-content-fade($direction: right, $size: 20%, $color: #fff, $edge: 0, $z-index: false) { + content: ""; + display: block; + position: absolute; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + + @if $z-index { + z-index: $z-index; + } + + @if $direction == "bottom" { + background: linear-gradient(to top, rgba($color, 0), $color 90%); + left: $edge; + right: $edge; + top: $edge; + bottom: calc(100% - $size); + width: auto; + } + + @if $direction == "top" { + background: linear-gradient(to bottom, rgba($color, 0), $color 90%); + top: calc(100% - $size); + left: $edge; + right: $edge; + bottom: $edge; + width: auto; + } + + @if $direction == "left" { + background: linear-gradient(to left, rgba($color, 0), $color 90%); + top: $edge; + left: $edge; + bottom: $edge; + right: auto; + width: $size; + height: auto; + } + + @if $direction == "right" { + background: linear-gradient(to right, rgba($color, 0), $color 90%); + top: $edge; + bottom: $edge; + right: $edge; + left: auto; + width: $size; + height: auto; + } +} + +/** + * Button states and focus styles + */ + +// Buttons with rounded corners. +@mixin button-style__disabled { + opacity: 0.6; + cursor: default; +} + +@mixin button-style__hover { + background-color: $white; + color: $dark-gray-900; + box-shadow: inset 0 0 0 1px $dark-gray-500, inset 0 0 0 2px $white; +} + +@mixin button-style__active() { + outline: none; + background-color: $white; + color: $dark-gray-900; + box-shadow: inset 0 0 0 1px $light-gray-700, inset 0 0 0 2px $white; +} + +@mixin button-style__focus-active() { + background-color: $white; + color: $dark-gray-900; + box-shadow: inset 0 0 0 1px $dark-gray-300, inset 0 0 0 2px $white; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; +} + +// Switch. +@mixin switch-style__focus-active() { + box-shadow: 0 0 0 2px $white, 0 0 0 3px $dark-gray-300; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: 2px; +} + +// Formatting Buttons. +@mixin formatting-button-style__hover { + color: $dark-gray-500; + box-shadow: inset 0 0 0 1px $dark-gray-500, inset 0 0 0 2px $white; +} + +@mixin formatting-button-style__active() { + outline: none; + color: $white; + box-shadow: none; + background: $dark-gray-500; +} + +@mixin formatting-button-style__focus() { + box-shadow: inset 0 0 0 1px $dark-gray-500, inset 0 0 0 2px $white; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; +} + +// Tabs, Inputs, Square buttons. +@mixin input-style__neutral() { + box-shadow: 0 0 0 transparent; + transition: box-shadow 0.1s linear; + border-radius: $radius-round-rectangle; + border: $border-width solid $dark-gray-200; + @include reduce-motion("transition"); +} + +@mixin input-style__focus() { + color: $dark-gray-900; + border-color: $blue-medium-focus; + box-shadow: 0 0 0 1px $blue-medium-focus; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; +} + +// Square buttons. +@mixin square-style__neutral() { + outline-offset: -1px; +} + +@mixin square-style__focus() { + color: $dark-gray-900; + outline-offset: -1px; + outline: 1px dotted $dark-gray-500; +} + +// Menu items. +@mixin menu-style__neutral() { + border: none; + box-shadow: none; +} + +@mixin menu-style__hover() { + color: $dark-gray-900; + border: none; + box-shadow: none; + background: $light-gray-200; +} + +@mixin menu-style__focus() { + color: $dark-gray-900; + border: none; + box-shadow: none; + outline-offset: -2px; + outline: 1px dotted $dark-gray-500; +} + +// Blocks in the Library. +@mixin block-style__disabled { + opacity: 0.6; + cursor: default; +} + +@mixin block-style__hover { + background: $light-gray-200; + color: $dark-gray-900; +} + +@mixin block-style__focus() { + color: $dark-gray-900; + box-shadow: 0 0 0 1px $white, 0 0 0 3px $blue-medium-500; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; +} + +@mixin block-style__is-active() { + color: $dark-gray-900; + box-shadow: inset 0 0 0 2px $dark-gray-500; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: -2px; +} + +@mixin block-style__is-active-focus() { + color: $dark-gray-900; + box-shadow: 0 0 0 1px $white, 0 0 0 3px $blue-medium-500, inset 0 0 0 2px $dark-gray-500; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 4px solid transparent; + outline-offset: -4px; +} + +/** + * Applies editor left position to the selector passed as argument + */ + +@mixin editor-left($selector) { + #{$selector} { /* Set left position when auto-fold is not on the body element. */ + left: 0; + + @include break-medium() { + left: $admin-sidebar-width; + } + } + + .auto-fold #{$selector} { /* Auto fold is when on smaller breakpoints, nav menu auto collapses. */ + @include break-medium() { + left: $admin-sidebar-width-collapsed; + } + + @include break-large() { + left: $admin-sidebar-width; + } + } + + /* Sidebar manually collapsed. */ + .folded #{$selector} { + left: 0; + + @include break-medium() { + left: $admin-sidebar-width-collapsed; + } + } + + /* Mobile menu opened. */ + @media (max-width: #{ ($break-medium) }) { + .auto-fold .wp-responsive-open #{$selector} { + left: $admin-sidebar-width-big; + } + } + + /* In small screens with responsive menu expanded there is small white space. */ + @media (max-width: #{ ($break-small) }) { + .auto-fold .wp-responsive-open #{$selector} { + margin-left: -18px; + } + } + + body.is-fullscreen-mode #{$selector} { + left: 0 !important; + } +} + +/** + * Applies editor right position to the selector passed as argument + */ + +@mixin editor-right($selector) { + #{ $selector } { + right: 0; + } + + .edit-post-layout.is-sidebar-opened #{ $selector } { + right: $sidebar-width; + } +} + + +/** + * Styles that are reused verbatim in a few places + */ + +// These are additional styles for all captions, when the theme opts in to block styles. +@mixin caption-style() { + margin-top: 0.5em; + margin-bottom: 1em; +} + +@mixin caption-style-theme() { + color: $dark-gray-500; + font-size: $default-font-size; + text-align: center; +} + +@mixin dropdown-arrow() { + content: ""; + pointer-events: none; + display: block; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 5px solid; + margin-left: $grid-size-small; + + // This gives the icon space on the right side consistent with the material + // icon standards. + margin-right: 2px; +} + +/** + * Allows users to opt-out of animations via OS-level preferences. + */ + +@mixin reduce-motion($property: "") { + + @if $property == "transition" { + @media (prefers-reduced-motion: reduce) { + transition-duration: 0s; + } + } + + @else if $property == "animation" { + @media (prefers-reduced-motion: reduce) { + animation-duration: 1ms; + } + } + + @else { + @media (prefers-reduced-motion: reduce) { + transition-duration: 0s; + animation-duration: 1ms; + } + } + +} + +/** + * Reset default styles for JavaScript UI based pages. + * This is a WP-admin agnostic reset + */ +@mixin reset { + box-sizing: border-box; + + *, + *::before, + *::after { + box-sizing: inherit; + } + + .input-control, // Upstream name is `.regular-text`. + input[type="text"], + input[type="search"], + input[type="radio"], + input[type="tel"], + input[type="time"], + input[type="url"], + input[type="week"], + input[type="password"], + input[type="checkbox"], + input[type="color"], + input[type="date"], + input[type="datetime"], + input[type="datetime-local"], + input[type="email"], + input[type="month"], + input[type="number"], + select, + textarea { + font-family: $default-font; + padding: 6px 8px; + @include input-style__neutral(); + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + /* Override core line-height. To be reviewed. */ + line-height: normal; + @include break-small { + font-size: $default-font-size; + /* Override core line-height. To be reviewed. */ + line-height: normal; + } + + &:focus { + @include input-style__focus(); + } + } + + input[type="number"] { + padding-left: 4px; + padding-right: 4px; + } + + select { + padding: 2px; + font-size: $default-font-size; + color: $dark-gray-500; + + &:focus { + border-color: $blue-medium-600; + // Windows High Contrast mode will show this outline + outline: 2px solid transparent; + outline-offset: 0; + } + } + + input[type="checkbox"], + input[type="radio"] { + border: $border-width + 1 solid $dark-gray-300; + margin-right: 12px; + transition: none; + + &:focus { + border-color: $dark-gray-300; + box-shadow: 0 0 0 1px $dark-gray-300; + } + + &:checked { + background: theme(toggle); + border-color: theme(toggle); + } + + &:checked:focus { + box-shadow: 0 0 0 2px $dark-gray-500; + } + } + + input[type="checkbox"] { + border-radius: $radius-round-rectangle / 2; + + &:checked::before, + &[aria-checked="mixed"]::before { + margin: -3px -5px; + color: $white; + + @include break-medium() { + margin: -4px 0 0 -5px; + } + } + + &[aria-checked="mixed"] { + background: theme(toggle); + border-color: theme(toggle); + + &::before { + // Inherited from `forms.css`. + // See: https://github.com/WordPress/wordpress-develop/tree/5.1.1/src/wp-admin/css/forms.css#L122-L132 + content: "\f460"; + float: left; + display: inline-block; + vertical-align: middle; + width: 16px; + /* stylelint-disable */ + font: normal 30px/1 dashicons; + /* stylelint-enable */ + speak: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @include break-medium() { + float: none; + font-size: 21px; + } + } + + &:focus { + box-shadow: 0 0 0 2px $dark-gray-500; + } + } + } + + // We provide explicit pixel dimensions to ensure a crisp appearance. + // This radio button style should be ported upstream. + input[type="radio"] { + border-radius: $radius-round; + + &:checked::before { + width: 6px; + height: 6px; + margin: 6px 0 0 6px; + background-color: $white; + + @include break-medium() { + margin: 3px 0 0 3px; + } + } + } + + // Placeholder colors + input, + textarea { + // Use opacity to work in various editor styles. + &::-webkit-input-placeholder { + color: $dark-opacity-300; + } + + &::-moz-placeholder { + opacity: 1; // Necessary because Firefox reduces this from 1. + color: $dark-opacity-300; + } + + &:-ms-input-placeholder { + color: $dark-opacity-300; + } + + .is-dark-theme & { + &::-webkit-input-placeholder { + color: $light-opacity-300; + } + + &::-moz-placeholder { + opacity: 1; // Necessary because Firefox reduces this from 1. + color: $light-opacity-300; + } + + &:-ms-input-placeholder { + color: $light-opacity-300; + } + } + } +} + +/** + * Reset the WP Admin page styles for Gutenberg-like pages. + */ +@mixin wp-admin-reset( $content-container ) { + background: $white; + + #wpcontent { + padding-left: 0; + } + + #wpbody-content { + padding-bottom: 0; + } + + /* We hide legacy notices in Gutenberg Based Pages, because they were not designed in a way that scaled well. + Plugins can use Gutenberg notices if they need to pass on information to the user when they are editing. */ + #wpbody-content > div:not(#{ $content-container }):not(#screen-meta) { + display: none; + } + + #wpfooter { + display: none; + } + + .a11y-speak-region { + left: -1px; + top: -1px; + } + + ul#adminmenu a.wp-has-current-submenu::after, + ul#adminmenu > li.current > a.current::after { + border-right-color: $white; + } + + .media-frame select.attachment-filters:last-of-type { + width: auto; + max-width: 100%; + } +} diff --git a/gb-src/assets/stylesheets/_variables.scss b/gb-src/assets/stylesheets/_variables.scss new file mode 100644 index 0000000000000..652be4700ff22 --- /dev/null +++ b/gb-src/assets/stylesheets/_variables.scss @@ -0,0 +1,71 @@ +/** + * Often re-used variables + */ + +// Fonts & basics +$default-font: -apple-system, BlinkMacSystemFont,"Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell,"Helvetica Neue", sans-serif; +$default-font-size: 13px; +$default-line-height: 1.4; +$editor-font: "Noto Serif", serif; +$editor-html-font: Menlo, Consolas, monaco, monospace; +$editor-font-size: 16px; +$default-block-margin: 28px; // This value provides a consistent, contiguous spacing between blocks (it's 2x $block-padding). +$text-editor-font-size: 14px; +$editor-line-height: 1.8; +$big-font-size: 18px; +$mobile-text-min-font-size: 16px; // Any font size below 16px will cause Mobile Safari to "zoom in" + +// Grid size +$grid-size-small: 4px; +$grid-size: 8px; +$grid-size-large: 16px; +$grid-size-xlarge: 24px; + +// Widths, heights & dimensions +$panel-padding: 16px; +$header-height: 56px; +$panel-header-height: 50px; +$admin-bar-height: 32px; +$admin-bar-height-big: 46px; +$admin-sidebar-width: 160px; +$admin-sidebar-width-big: 190px; +$admin-sidebar-width-collapsed: 36px; +$empty-paragraph-height: $text-editor-font-size * 4; +$modal-min-width: 360px; + +// Visuals +$shadow-popover: 0 3px 30px rgba($dark-gray-900, 0.1); +$shadow-toolbar: 0 2px 10px rgba($dark-gray-900, 0.1), 0 0 2px rgba($dark-gray-900, 0.1); +$shadow-below-only: 0 5px 10px rgba($dark-gray-900, 0.05), 0 2px 2px rgba($dark-gray-900, 0.05); +$shadow-modal: 0 3px 30px rgba($dark-gray-900, 0.2); + +// Editor Widths +$sidebar-width: 280px; +$content-width: 610px; // For the visual width, subtract 30px (2 * $block-padding + 2px borders). This comes to 580px, which is optimized for 70 characters. + +// Block UI +$border-width: 1px; +$block-controls-height: 36px; +$icon-button-size: 36px; +$icon-button-size-small: 24px; +$inserter-tabs-height: 36px; +$block-toolbar-height: $block-controls-height + $border-width; +$resize-handler-size: 15px; +$resize-handler-container-size: $resize-handler-size + ($grid-size-small * 2); // Make the resize handle container larger so there's a larger grabbable area. + +// Blocks +$block-left-border-width: $border-width * 3; +$block-padding: 14px; // Space between block footprint and focus boundaries. These are drawn outside the block footprint, and do not affect the size. +$block-spacing: 4px; // Vertical space between blocks. +$block-side-ui-width: 28px; // Width of the movers/drag handle UI. +$block-side-ui-clearance: 2px; // Space between movers/drag handle UI, and block. +$block-container-side-padding: $block-side-ui-width + $block-padding + 2 * $block-side-ui-clearance; // Total space left and right of the block footprint. +$block-bg-padding--v: $block-padding + $block-spacing + $block-side-ui-clearance; // padding for Blocks with a background color (eg: paragraph or group) +$block-bg-padding--h: $block-side-ui-width + $block-side-ui-clearance; // padding for Blocks with a background color (eg: paragraph or group) + +// Buttons & UI Widgets +$radius-round-rectangle: 4px; +$radius-round: 50%; + +// Widgets screen +$widget-area-width: 700px; diff --git a/gb-src/assets/stylesheets/_z-index.scss b/gb-src/assets/stylesheets/_z-index.scss new file mode 100644 index 0000000000000..c93cf93a47254 --- /dev/null +++ b/gb-src/assets/stylesheets/_z-index.scss @@ -0,0 +1,125 @@ +// Stores a list of z-index values in a central location. For clarity, when a +// specific value is needed, add a comment explaining why (what other rules the +// value is designed to work with). + +$z-layers: ( + ".block-editor-block-list__block-edit::before": 0, + ".block-editor-block-switcher__arrow": 1, + ".block-editor-block-list__block {core/image aligned wide or fullwide}": 20, + ".block-library-classic__toolbar": 10, + ".block-editor-block-list__layout .reusable-block-indicator": 1, + ".block-editor-block-list__breadcrumb": 22, + ".components-form-toggle__input": 1, + ".components-panel__header.edit-post-sidebar__panel-tabs": -1, + ".edit-post-sidebar .components-panel": -2, + ".block-editor-inserter__tabs": 1, + ".block-editor-inserter__tab.is-active": 1, + ".components-panel__header": 1, + ".components-modal__header": 10, + ".edit-post-meta-boxes-area.is-loading::before": 1, + ".edit-post-meta-boxes-area .spinner": 5, + ".components-popover__close": 5, + ".block-editor-block-list__insertion-point": 6, + ".block-editor-inserter-with-shortcuts": 5, + ".block-editor-warning": 5, + ".block-library-gallery-item__inline-menu": 20, + ".block-editor-url-input__suggestions": 30, + ".edit-post-header": 30, + ".edit-widgets-header": 30, + ".block-library-button__inline-link .block-editor-url-input__suggestions": 6, // URL suggestions for button block above sibling inserter + ".block-library-image__resize-handlers": 1, // Resize handlers above sibling inserter + ".wp-block-cover__inner-container": 1, // InnerBlocks area inside cover image block + ".wp-block-cover.has-background-dim::before": 1, // Overlay area inside block cover need to be higher than the video background. + ".wp-block-cover__video-background": 0, // Video background inside cover block. + + // Active pill button + ".components-button.is-button {:focus or .is-primary}": 1, + + // The draggable element should show up above the entire UI + ".components-draggable__clone": 1000000000, + + // Should have higher index than the inset/underlay used for dragging + ".components-placeholder__fieldset": 1, + ".block-editor-block-list__block-edit .reusable-block-edit-panel *": 1, + + // Show drop zone above most standard content, but below any overlays + ".components-drop-zone": 40, + ".components-drop-zone__content": 50, + + // The block mover for floats should overlap the controls of adjacent blocks. + ".block-editor-block-list__block {core/image aligned left or right}": 21, + + // Small screen inner blocks overlay must be displayed above drop zone, + // settings menu, and movers. + ".block-editor-inner-blocks.has-overlay::after": 60, + + // The toolbar, when contextual, should be above any adjacent nested block click overlays. + ".block-editor-block-list__layout .reusable-block-edit-panel": 61, + ".block-editor-block-contextual-toolbar": 61, + ".editor-inner-blocks .block-editor-block-list__breadcrumb": 62, + + // The block mover, particularly in nested contexts, + // should overlap most block content. + ".block-editor-block-list__block.is-{selected,hovered} .block-editor-block-mover": 61, + + // Show sidebar above wp-admin navigation bar for mobile viewports: + // #wpadminbar { z-index: 99999 } + ".edit-post-sidebar": 100000, + ".edit-widgets-sidebar": 100000, + ".edit-post-layout .edit-post-post-publish-panel": 100001, + // For larger views, the wp-admin navbar dropdown should be at top of + // the Publish Post sidebar. + ".edit-post-layout .edit-post-post-publish-panel-break-medium": 99998, + + // Show sidebar in greater than small viewports above editor related elements + // but bellow #adminmenuback { z-index: 100 } + ".edit-post-sidebar {greater than small}": 90, + + // Show notices below expanded editor bar + // .edit-post-header { z-index: 30 } + ".components-notice-list": 29, + + + // Show snackbars above everything (similar to popovers) + ".components-snackbar-list": 100000, + + // Show modal under the wp-admin menus and the popover + ".components-modal__screen-overlay": 100000, + + // Show popovers above wp-admin menus and submenus and sidebar: + // #adminmenuwrap { z-index: 9990 } + ".components-popover": 1000000, + + // ...Except for popovers immediately beneath wp-admin menu on large breakpoints + ".components-popover.block-editor-inserter__popover": 99998, + ".components-popover.table-of-contents__popover": 99998, + ".components-popover.block-editor-block-navigation__popover": 99998, + ".components-popover.edit-post-more-menu__content": 99998, + ".components-popover.block-editor-rich-text__inline-format-toolbar": 99998, + + ".components-autocomplete__results": 1000000, + + ".skip-to-selected-block": 100000, + ".edit-post-toggle-publish-panel": 100000, + + // Show NUX tips above popovers, wp-admin menus, submenus, and sidebar: + ".nux-dot-tip": 1000001, + + // Show tooltips above NUX tips, wp-admin menus, submenus, and sidebar: + ".components-tooltip": 1000002, + + // Make sure corner handles are above side handles for ResizableBox component + ".components-resizable-box__side-handle": 1, + ".components-resizable-box__corner-handle": 2, + + // Make sure block manager sticky category titles appear above the options + ".edit-post-manage-blocks-modal__category-title": 1 +); + +@function z-index( $key ) { + @if map-has-key( $z-layers, $key ) { + @return map-get( $z-layers, $key ); + } + + @error "Error: Specified z-index `#{$key}` does not exist in the mapping"; +} diff --git a/gb-src/babel.config.js b/gb-src/babel.config.js new file mode 100644 index 0000000000000..7679cc1e83704 --- /dev/null +++ b/gb-src/babel.config.js @@ -0,0 +1,8 @@ +module.exports = function( api ) { + api.cache( true ); + + return { + presets: [ '@wordpress/babel-preset-default' ], + plugins: [ 'babel-plugin-inline-json-import' ], + }; +}; diff --git a/gb-src/bin/build-plugin-zip.sh b/gb-src/bin/build-plugin-zip.sh new file mode 100755 index 0000000000000..4db22d18b5acd --- /dev/null +++ b/gb-src/bin/build-plugin-zip.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# Exit if any command fails. +set -e + +# Change to the expected directory. +cd "$(dirname "$0")" +cd .. + +# Enable nicer messaging for build status. +BLUE_BOLD='\033[1;34m'; +GREEN_BOLD='\033[1;32m'; +RED_BOLD='\033[1;31m'; +YELLOW_BOLD='\033[1;33m'; +COLOR_RESET='\033[0m'; +error () { + echo -e "\n${RED_BOLD}$1${COLOR_RESET}\n" +} +status () { + echo -e "\n${BLUE_BOLD}$1${COLOR_RESET}\n" +} +success () { + echo -e "\n${GREEN_BOLD}$1${COLOR_RESET}\n" +} +warning () { + echo -e "\n${YELLOW_BOLD}$1${COLOR_RESET}\n" +} + +status "💃 Time to release Gutenberg 🕺" + +if [ -z "$NO_CHECKS" ]; then + # Make sure there are no changes in the working tree. Release builds should be + # traceable to a particular commit and reliably reproducible. (This is not + # totally true at the moment because we download nightly vendor scripts). + changed= + if ! git diff --exit-code > /dev/null; then + changed="file(s) modified" + elif ! git diff --cached --exit-code > /dev/null; then + changed="file(s) staged" + fi + if [ ! -z "$changed" ]; then + git status + error "ERROR: Cannot build plugin zip with dirty working tree. ☝️ + Commit your changes and try again." + exit 1 + fi + + # Do a dry run of the repository reset. Prompting the user for a list of all + # files that will be removed should prevent them from losing important files! + status "Resetting the repository to pristine condition. ✨" + to_clean=$(git clean -xdf --dry-run) + if [ ! -z "$to_clean" ]; then + echo $to_clean + warning "🚨 About to delete everything above! Is this okay? 🚨" + echo -n "[y]es/[N]o: " + read answer + if [ "$answer" != "${answer#[Yy]}" ]; then + # Remove ignored files to reset repository to pristine condition. Previous + # test ensures that changed files abort the plugin build. + status "Cleaning working directory... 🛀" + git clean -xdf + else + error "Fair enough; aborting. Tidy up your repo and try again. 🙂" + exit 1 + fi + fi +fi + +# Download all vendor scripts +status "Downloading remote vendor scripts... 🛵" +vendor_scripts="" +# Using `command | while read...` is more typical, but the inside of the `while` +# loop will run under a separate process this way, meaning that it cannot +# modify $vendor_scripts. See: https://stackoverflow.com/a/16855194 +exec 3< <( + # Get minified versions of vendor scripts. + php bin/get-vendor-scripts.php + # Get non-minified versions of vendor scripts (for SCRIPT_DEBUG). + php bin/get-vendor-scripts.php debug +) +while IFS='|' read -u 3 url filename; do + echo "$url" + echo -n " > vendor/$filename ... " + http_status=$( curl \ + --location \ + --silent \ + "$url" \ + --output "vendor/_download.tmp.js" \ + --write-out "%{http_code}" + ) + if [ "$http_status" != 200 ]; then + error "HTTP $http_status" + exit 1 + fi + mv -f "vendor/_download.tmp.js" "vendor/$filename" + echo -e "${GREEN_BOLD}done!${COLOR_RESET}" + vendor_scripts="$vendor_scripts vendor/$filename" +done + +# Run the build. +status "Installing dependencies... 📦" +PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install +status "Generating build... 👷‍♀️" +npm run build + +# Temporarily modify `gutenberg.php` with production constants defined. Use a +# temp file because `bin/generate-gutenberg-php.php` reads from `gutenberg.php` +# so we need to avoid writing to that file at the same time. +php bin/generate-gutenberg-php.php > gutenberg.tmp.php +mv gutenberg.tmp.php gutenberg.php + +build_files=$(ls build/*/*.{js,css,asset.php} build/block-library/blocks/*.php) + +# Generate the plugin zip file. +status "Creating archive... 🎁" +zip -r gutenberg.zip \ + gutenberg.php \ + lib/*.php \ + packages/block-serialization-default-parser/*.php \ + post-content.php \ + $vendor_scripts \ + $build_files \ + README.md + +# Reset `gutenberg.php`. +git checkout gutenberg.php + +success "Done. You've built Gutenberg! 🎉 " diff --git a/gb-src/bin/commander.js b/gb-src/bin/commander.js new file mode 100755 index 0000000000000..9edd90821d3bd --- /dev/null +++ b/gb-src/bin/commander.js @@ -0,0 +1,641 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ + +// Dependencies +const path = require( 'path' ); +const program = require( 'commander' ); +const inquirer = require( 'inquirer' ); +const semver = require( 'semver' ); +const chalk = require( 'chalk' ); +const fs = require( 'fs-extra' ); +const SimpleGit = require( 'simple-git/promise' ); +const childProcess = require( 'child_process' ); +const Octokit = require( '@octokit/rest' ); +const os = require( 'os' ); +const uuid = require( 'uuid/v4' ); + +// Config +const gitRepoOwner = 'WordPress'; +const gitRepoURL = 'git@github.com:' + gitRepoOwner + '/gutenberg.git'; +const svnRepoURL = 'https://plugins.svn.wordpress.org/gutenberg'; + +// Working Directories +const gitWorkingDirectoryPath = path.join( os.tmpdir(), uuid() ); +const svnWorkingDirectoryPath = path.join( os.tmpdir(), uuid() ); + +// UI +const error = chalk.bold.red; +const warning = chalk.bold.keyword( 'orange' ); +const success = chalk.bold.green; + +// Utils + +/** + * Asks the user for a confirmation to continue or abort otherwise + * + * @param {string} message Confirmation message. + * @param {boolean} isDefault Default reply. + * @param {string} abortMessage Abort message. + */ +async function askForConfirmationToContinue( message, isDefault = true, abortMessage = 'Aborting.' ) { + const { isReady } = await inquirer.prompt( [ { + type: 'confirm', + name: 'isReady', + default: isDefault, + message, + } ] ); + + if ( ! isReady ) { + console.log( error( '\n' + abortMessage ) ); + process.exit( 1 ); + } +} + +/** + * Common logic wrapping a step in the process. + * + * @param {string} name Step name. + * @param {string} abortMessage Abort message. + * @param {Function} handler Step logic. + */ +async function runStep( name, abortMessage, handler ) { + try { + await handler(); + } catch ( exception ) { + console.log( + error( 'The following error happened during the "' + warning( name ) + '" step:' ) + '\n\n', + exception, + error( '\n\n' + abortMessage ) + ); + + process.exit( 1 ); + } +} + +/** + * Utility to run a child script + * + * @param {string} script Script to run. + * @param {string} cwd Working directory. + */ +function runShellScript( script, cwd ) { + childProcess.execSync( script, { + cwd, + env: { + NO_CHECKS: true, + PATH: process.env.PATH, + }, + stdio: [ 'inherit', 'ignore', 'inherit' ], + } ); +} + +// Steps + +/** + * Clone the Gutenberg repository to the working directory. + * + * @param {string} abortMessage Abort message. + */ +async function runGitRepositoryCloneStep( abortMessage ) { + // Cloning the repository + await runStep( 'Cloning the Git repository', abortMessage, async () => { + console.log( '>> Cloning the Git repository' ); + const simpleGit = SimpleGit(); + await simpleGit.clone( gitRepoURL, gitWorkingDirectoryPath ); + console.log( '>> The Gutenberg Git repository has been successfully cloned in the following temporary folder: ' + success( gitWorkingDirectoryPath ) ); + } ); +} + +/** + * Fetching the SVN Gutenberg repository to the working directory. + * + * @param {string} abortMessage Abort message. + */ +async function runSvnRepositoryCloneStep( abortMessage ) { + // Cloning the repository + await runStep( 'Fetching the SVN repository', abortMessage, async () => { + console.log( '>> Fetching the SVN repository' ); + runShellScript( 'svn checkout ' + svnRepoURL + '/trunk ' + svnWorkingDirectoryPath ); + console.log( '>> The Gutenberg SVN repository has been successfully fetched in the following temporary folder: ' + success( svnWorkingDirectoryPath ) ); + } ); +} + +/** + * Updates and commits the content of the SVN repo using the new plugin ZIP. + * + * @param {string} version Version. + * @param {string} changelog Changelog. + * @param {string} abortMessage Abort Message. + */ +async function runUpdateTrunkContentStep( version, changelog, abortMessage ) { + // Updating the content of the svn + await runStep( 'Updating trunk content', abortMessage, async () => { + console.log( '>> Replacing trunk content using the new plugin ZIP' ); + + // Delete everything except readme.txt and changelog.txt + runShellScript( 'find . -maxdepth 1 -not -name "changelog.txt" -not -name "readme.txt" -not -name ".svn" -not -name "." -not -name ".." -exec rm -rf {} +', svnWorkingDirectoryPath ); + + // Update the content using the plugin ZIP + const gutenbergZipPath = gitWorkingDirectoryPath + '/gutenberg.zip'; + runShellScript( 'unzip ' + gutenbergZipPath + ' -d ' + svnWorkingDirectoryPath ); + + console.log( '>> Updating the changelog in readme.txt and changelog.txt' ); + + // Update the content of the readme.txt file + const readmePath = svnWorkingDirectoryPath + '/readme.txt'; + const readmeFileContent = fs.readFileSync( readmePath, 'utf8' ); + const newReadmeContent = + readmeFileContent.substr( 0, readmeFileContent.indexOf( '== Changelog ==' ) ) + + '== Changelog ==\n\n' + + changelog + '\n'; + fs.writeFileSync( readmePath, newReadmeContent ); + + // Update the content of the changelog.txt file + const changelogPath = svnWorkingDirectoryPath + '/changelog.txt'; + const changelogFileContent = fs.readFileSync( changelogPath, 'utf8' ); + const newChangelogContent = + '== Changelog ==\n\n' + + '= ' + version + ' =\n\n' + + changelog + + changelogFileContent.substr( changelogFileContent.indexOf( '== Changelog ==' ) + 16 ); + fs.writeFileSync( changelogPath, newChangelogContent ); + + // Commit the content changes + runShellScript( "svn st | grep '^\?' | awk '{print $2}' | xargs svn add", svnWorkingDirectoryPath ); + runShellScript( "svn st | grep '^!' | awk '{print $2}' | xargs svn rm", svnWorkingDirectoryPath ); + await askForConfirmationToContinue( + 'Trunk content has been updated, please check the SVN diff. Commit the changes?', + true, + abortMessage + ); + + runShellScript( 'svn commit -m "Committing Gutenberg version ' + version + '"', svnWorkingDirectoryPath ); + + console.log( '>> Trunk has been successfully updated' ); + } ); +} + +/** + * Creates a new SVN Tag + * + * @param {string} version Version. + * @param {string} abortMessage Abort Message. + */ +async function runSvnTagStep( version, abortMessage ) { + await runStep( 'Creating the SVN Tag', abortMessage, async () => { + await askForConfirmationToContinue( + 'Proceed with the creation of the SVN Tag?', + true, + abortMessage + ); + runShellScript( 'svn cp ' + svnRepoURL + '/trunk ' + svnRepoURL + '/tags/' + version + ' -m "Tagging Gutenberg version ' + version + '"' ); + + console.log( '>> The SVN ' + success( version ) + ' tag has been successfully created' ); + } ); +} + +/** + * Updates the stable version of the plugin in the SVN repository. + * + * @param {string} version Version. + * @param {string} abortMessage Abort Message. + */ +async function updateThePluginStableVersion( version, abortMessage ) { + // Updating the content of the svn + await runStep( 'Updating the plugin\'s stable version', abortMessage, async () => { + const readmePath = svnWorkingDirectoryPath + '/readme.txt'; + const readmeFileContent = fs.readFileSync( readmePath, 'utf8' ); + const newReadmeContent = readmeFileContent.replace( + /Stable tag: [0-9]+.[0-9]+.[0-9]+\s*\n/, + 'Stable tag: ' + version + '\n' + ); + fs.writeFileSync( readmePath, newReadmeContent ); + + // Commit the content changes + await askForConfirmationToContinue( + 'The stable version is updated in the readme.txt file. Commit the changes?', + true, + abortMessage + ); + + runShellScript( 'svn commit -m "Releasing Gutenberg version ' + version + '"', svnWorkingDirectoryPath ); + + console.log( '>> Stable version updated successfully' ); + } ); +} + +/** + * Clean the working directory. + * + * @param {string} abortMessage Abort message. + */ +async function runCleanLocalCloneStep( abortMessage ) { + await runStep( 'Cleaning the temporary folder', abortMessage, async () => { + await fs.remove( gitWorkingDirectoryPath ); + await fs.remove( svnWorkingDirectoryPath ); + } ); +} + +/** + * Creates a new release branch based on the last package.json version + * and chooses the next RC version number. + * + * @param {string} abortMessage Abort Message. + * + * @return {Object} chosen version and versionLabels. + */ +async function runReleaseBranchCreationStep( abortMessage ) { + let version, releaseBranch, versionLabel; + await runStep( 'Creating the release branch', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + const packageJsonPath = gitWorkingDirectoryPath + '/package.json'; + const packageJson = require( packageJsonPath ); + const parsedVersion = semver.parse( packageJson.version ); + + // Follow the WordPress version guidelines to compute the version to be used + // By default, increase the "minor" number but if we reach 9, bump to the next major. + if ( parsedVersion.minor === 9 ) { + version = ( parsedVersion.major + 1 ) + '.0.0-rc.1'; + releaseBranch = 'release/' + ( parsedVersion.major + 1 ) + '.0'; + versionLabel = ( parsedVersion.major + 1 ) + '.0.0 RC1'; + } else { + version = parsedVersion.major + '.' + ( parsedVersion.minor + 1 ) + '.0-rc.1'; + releaseBranch = 'release/' + parsedVersion.major + '.' + ( parsedVersion.minor + 1 ); + versionLabel = parsedVersion.major + '.' + ( parsedVersion.minor + 1 ) + '.0 RC1'; + } + await askForConfirmationToContinue( + 'The Plugin version to be used is ' + success( version ) + '. Proceed with the creation of the release branch?', + true, + abortMessage + ); + + // Creating the release branch + await simpleGit.checkoutLocalBranch( releaseBranch ); + console.log( '>> The local release branch ' + success( releaseBranch ) + ' has been successfully created.' ); + } ); + + return { + version, + versionLabel, + releaseBranch, + }; +} + +/** + * Checkouts out the release branch and chooses a stable version number. + * + * @param {string} abortMessage Abort Message. + * + * @return {Object} chosen version and versionLabels. + */ +async function runReleaseBranchCheckoutStep( abortMessage ) { + let releaseBranch, version; + await runStep( 'Getting into the release branch', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + const packageJsonPath = gitWorkingDirectoryPath + '/package.json'; + const masterPackageJson = require( packageJsonPath ); + const masterParsedVersion = semver.parse( masterPackageJson.version ); + releaseBranch = 'release/' + masterParsedVersion.major + '.' + masterParsedVersion.minor; + + // Creating the release branch + await simpleGit.checkout( releaseBranch ); + console.log( '>> The local release branch ' + success( releaseBranch ) + ' has been successfully checked out.' ); + + const releaseBranchPackageJson = require( packageJsonPath ); + const releaseBranchParsedVersion = semver.parse( releaseBranchPackageJson.version ); + + if ( releaseBranchParsedVersion.prerelease && releaseBranchParsedVersion.prerelease.length ) { + version = releaseBranchParsedVersion.major + '.' + releaseBranchParsedVersion.minor + '.' + releaseBranchParsedVersion.patch; + } else { + version = releaseBranchParsedVersion.major + '.' + releaseBranchParsedVersion.minor + '.' + ( releaseBranchParsedVersion.patch + 1 ); + } + + await askForConfirmationToContinue( + 'The Version to release is ' + success( version ) + '. Proceed?', + true, + abortMessage + ); + } ); + + return { + version, + versionLabel: version, + releaseBranch, + }; +} + +/** + * Bump the version in the different files (package.json, package-lock.json, gutenberg.php) + * and commit the changes. + * + * @param {string} version Version to use. + * @param {string} abortMessage Abort message. + * + * @return {string} hash of the version bump commit. + */ +async function runBumpPluginVersionAndCommitStep( version, abortMessage ) { + let commitHash; + await runStep( 'Updating the plugin version', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + const packageJsonPath = gitWorkingDirectoryPath + '/package.json'; + const packageLockPath = gitWorkingDirectoryPath + '/package-lock.json'; + const pluginFilePath = gitWorkingDirectoryPath + '/gutenberg.php'; + const packageJson = require( packageJsonPath ); + const packageLock = require( packageLockPath ); + const newPackageJson = { + ...packageJson, + version, + }; + fs.writeFileSync( packageJsonPath, JSON.stringify( newPackageJson, null, '\t' ) + '\n' ); + const newPackageLock = { + ...packageLock, + version, + }; + fs.writeFileSync( packageLockPath, JSON.stringify( newPackageLock, null, '\t' ) + '\n' ); + const content = fs.readFileSync( pluginFilePath, 'utf8' ); + fs.writeFileSync( pluginFilePath, content.replace( ' * Version: ' + packageJson.version, ' * Version: ' + version ) ); + console.log( '>> The plugin version has been updated successfully.' ); + + // Commit the version bump + await askForConfirmationToContinue( + 'Please check the diff. Proceed with the version bump commit?', + true, + abortMessage + ); + await simpleGit.add( [ + packageJsonPath, + packageLockPath, + pluginFilePath, + ] ); + const commitData = await simpleGit.commit( 'Bump plugin version to ' + version ); + commitHash = commitData.commit; + console.log( '>> The plugin version bump has been commited successfully.' ); + } ); + + return commitHash; +} + +/** + * Run the Plugin ZIP Creation step. + * + * @param {string} abortMessage Abort message. + */ +async function runPluginZIPCreationStep( abortMessage ) { + await runStep( 'Plugin ZIP creation', abortMessage, async () => { + const gutenbergZipPath = gitWorkingDirectoryPath + '/gutenberg.zip'; + await askForConfirmationToContinue( + 'Proceed and build the plugin ZIP? (It takes a few minutes)', + true, + abortMessage + ); + runShellScript( '/bin/bash bin/build-plugin-zip.sh', gitWorkingDirectoryPath ); + + console.log( '>> The plugin ZIP has been built successfully. Path: ' + success( gutenbergZipPath ) ); + } ); +} + +/** + * Create a local Git Tag. + * + * @param {string} version Version to use. + * @param {string} abortMessage Abort message. + */ +async function runCreateGitTagStep( version, abortMessage ) { + await runStep( 'Creating the git tag', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + await askForConfirmationToContinue( + 'Proceed with the creation of the git tag?', + true, + abortMessage + ); + await simpleGit.addTag( 'v' + version ); + console.log( '>> The ' + success( 'v' + version ) + ' tag has been created successfully.' ); + } ); +} + +/** + * Push the local Git Changes and Tags to the remote repository. + * + * @param {string} releaseBranch Release branch name. + * @param {string} abortMessage Abort message. + */ +async function runPushGitChangesStep( releaseBranch, abortMessage ) { + await runStep( 'Pushing the release branch and the tag', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + await askForConfirmationToContinue( + 'The release branch and the tag are going to be pushed to the remote repository. Continue?', + true, + abortMessage + ); + await simpleGit.push( 'origin', releaseBranch ); + await simpleGit.pushTags( 'origin' ); + } ); +} + +/** + * Creates the github release and uploads the Gutenberg ZIP file into it. + * + * @param {string} version Released version. + * @param {string} versionLabel Label of the released Version. + * @param {boolean} isPrerelease is a pre-release. + * @param {string} abortMessage Abort message. + * + * @return {Object} Github release object. + */ +async function runGithubReleaseStep( version, versionLabel, isPrerelease, abortMessage ) { + let octokit; + let release; + await runStep( 'Creating the GitHub release', abortMessage, async () => { + await askForConfirmationToContinue( + 'Proceed with the creation of the GitHub release?', + true, + abortMessage + ); + const { changelog } = await inquirer.prompt( [ { + type: 'editor', + name: 'changelog', + message: 'Please provide the CHANGELOG of the release (markdown)', + } ] ); + + const { token } = await inquirer.prompt( [ { + type: 'input', + name: 'token', + message: 'Please provide a GitHub personal authentication token. Navigate to ' + success( 'https://github.com/settings/tokens/new?scopes=repo,admin:org,write:packages' ) + ' to create one.', + } ] ); + + octokit = new Octokit( { + auth: token, + } ); + + const releaseData = await octokit.repos.createRelease( { + owner: gitRepoOwner, + repo: 'gutenberg', + tag_name: 'v' + version, + name: versionLabel, + body: changelog, + prerelease: isPrerelease, + } ); + release = releaseData.data; + + console.log( '>> The GitHub release has been created.' ); + } ); + abortMessage = abortMessage + ' Make sure to remove the the GitHub release as well.'; + + // Uploading the Gutenberg Zip to the release + await runStep( 'Uploading the plugin ZIP', abortMessage, async () => { + const gutenbergZipPath = gitWorkingDirectoryPath + '/gutenberg.zip'; + const filestats = fs.statSync( gutenbergZipPath ); + await octokit.repos.uploadReleaseAsset( { + url: release.upload_url, + headers: { + 'content-length': filestats.size, + 'content-type': 'application/zip', + }, + name: 'gutenberg.zip', + file: fs.createReadStream( gutenbergZipPath ), + } ); + console.log( '>> The plugin ZIP has been successfully uploaded.' ); + } ); + + console.log( '>> The GitHub release is available here: ' + success( release.html_url ) ); + + return release; +} + +/** + * Cherry-picks the version bump commit into master. + * + * @param {string} commitHash Commit to cherry-pick. + * @param {string} abortMessage Abort message. + */ +async function runCherrypickBumpCommitIntoMasterStep( commitHash, abortMessage ) { + await runStep( 'Cherry-picking the bump commit into master', abortMessage, async () => { + const simpleGit = SimpleGit( gitWorkingDirectoryPath ); + await askForConfirmationToContinue( + 'The plugin is now released. Proceed with the version bump in the master branch?', + true, + abortMessage + ); + await simpleGit.fetch(); + await simpleGit.reset( 'hard' ); + await simpleGit.checkout( 'master' ); + await simpleGit.pull( 'origin', 'master' ); + await simpleGit.raw( [ 'cherry-pick', commitHash ] ); + await simpleGit.push( 'origin', 'master' ); + } ); +} + +/** + * Release a new Gutenberg version. + * + * @param {boolean} isRC Whether it's an RC release or not. + * + * @return {Object} Github release object. + */ +async function releasePlugin( isRC = true ) { + // This is a variable that contains the abort message shown when the script is aborted. + let abortMessage = 'Aborting!'; + await askForConfirmationToContinue( 'Ready to go? ' ); + + // Cloning the Git repository + await runGitRepositoryCloneStep( abortMessage ); + + // Creating the release branch + const { version, versionLabel, releaseBranch } = isRC ? + await runReleaseBranchCreationStep( abortMessage ) : + await runReleaseBranchCheckoutStep( abortMessage ); + + // Bumping the version and commit. + const commitHash = await runBumpPluginVersionAndCommitStep( version, abortMessage ); + + // Plugin ZIP creation + await runPluginZIPCreationStep(); + + // Creating the git tag + await runCreateGitTagStep( version, abortMessage ); + + // Push the local changes + await runPushGitChangesStep( releaseBranch, abortMessage ); + abortMessage = 'Aborting! Make sure to ' + isRC ? 'remove' : 'reset' + ' the remote release branch and remove the git tag.'; + + // Creating the GitHub Release + const release = await runGithubReleaseStep( version, versionLabel, isRC, abortMessage ); + abortMessage = 'Aborting! Make sure to manually cherry-pick the ' + success( commitHash ) + ' commit to the master branch.'; + if ( ! isRC ) { + abortMessage += ' Make sure to perform the SVN release manually as well.'; + } + + // Cherry-picking the bump commit into master + await runCherrypickBumpCommitIntoMasterStep( commitHash, abortMessage ); + + if ( ! isRC ) { + abortMessage = 'Aborting! The GitHub release is done. Make sure to perform the SVN release manually.'; + + await askForConfirmationToContinue( 'The GitHub release is complete. Proceed with the SVN release? ', abortMessage ); + + // Fetching the SVN repository + await runSvnRepositoryCloneStep( abortMessage ); + + // Updating the SVN trunk content + await runUpdateTrunkContentStep( version, release.body, abortMessage ); + + abortMessage = 'Aborting! The GitHub release is done, SVN trunk updated. Make sure to create the SVN tag and update the stable version manually.'; + await runSvnTagStep( version, abortMessage ); + + abortMessage = 'Aborting! The GitHub release is done, SVN tagged. Make sure to update the stable version manually.'; + await updateThePluginStableVersion( version, abortMessage ); + } + + abortMessage = 'Aborting! The release is finished though.'; + await runCleanLocalCloneStep( abortMessage ); + + return release; +} + +program + .command( 'release-plugin-rc' ) + .alias( 'rc' ) + .description( 'Release an RC version of the plugin (supports only rc.1 for now)' ) + .action( async () => { + console.log( + chalk.bold( '💃 Time to release Gutenberg 🕺\n\n' ), + 'Welcome! This tool is going to help you release a new RC version of the Gutenberg Plugin.\n', + 'It goes through different steps : creating the release branch, bumping the plugin version, tagging and creating the GitHub release, building the ZIP...\n', + 'To perform a release you\'ll have to be a member of the Gutenberg Core Team.\n' + ); + + const release = await releasePlugin( true ); + + console.log( + '\n>> 🎉 The Gutenberg version ' + success( release.name ) + ' has been successfully released.\n', + 'You can access the GitHub release here: ' + success( release.html_url ) + '\n', + 'Thanks for performing the release!' + ); + } ); + +program + .command( 'release-plugin-stable' ) + .alias( 'stable' ) + .description( 'Release a stable version of the plugin' ) + .action( async () => { + console.log( + chalk.bold( '💃 Time to release Gutenberg 🕺\n\n' ), + 'Welcome! This tool is going to help you release a new stable version of the Gutenberg Plugin.\n', + 'It goes through different steps : bumping the plugin version, tagging and creating the GitHub release, building the ZIP, pushing the release to the SVN repository...\n', + 'To perform a release you\'ll have to be a member of the Gutenberg Core Team.\n' + ); + + const release = await releasePlugin( false ); + + console.log( + '\n>> 🎉 The Gutenberg ' + success( release.name ) + ' has been successfully released.\n', + 'You can access the GitHub release here: ' + success( release.html_url ) + '\n', + 'In a few minutes, you\'ll be able to update the plugin from the WordPress repository.\n', + 'Thanks for performing the release! and don\'t forget to publish the release post.' + ); + } ); + +program.parse( process.argv ); + +/* eslint-enable no-console */ diff --git a/gb-src/bin/docker-compose.override.yml.template b/gb-src/bin/docker-compose.override.yml.template new file mode 100644 index 0000000000000..465211fe7a4a6 --- /dev/null +++ b/gb-src/bin/docker-compose.override.yml.template @@ -0,0 +1,17 @@ +services: + wordpress-develop: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + - %PLUGIN_MOUNT_DIR%/packages/e2e-tests/plugins:/var/www/${LOCAL_DIR-src}/wp-content/plugins/gutenberg-test-plugins + - %PLUGIN_MOUNT_DIR%/packages/e2e-tests/mu-plugins:/var/www/${LOCAL_DIR-src}/wp-content/mu-plugins + php: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + - %PLUGIN_MOUNT_DIR%/packages/e2e-tests/plugins:/var/www/${LOCAL_DIR-src}/wp-content/plugins/gutenberg-test-plugins + - %PLUGIN_MOUNT_DIR%/packages/e2e-tests/mu-plugins:/var/www/${LOCAL_DIR-src}/wp-content/mu-plugins + cli: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + phpunit: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% diff --git a/gb-src/bin/generate-gutenberg-php.php b/gb-src/bin/generate-gutenberg-php.php new file mode 100755 index 0000000000000..fc9bf51ef3cab --- /dev/null +++ b/gb-src/bin/generate-gutenberg-php.php @@ -0,0 +1,62 @@ +#!/usr/bin/env php += 0; +} + +function flattenUnary( expression ) { + const shouldWrap = isGroup( expression ); + const inner = flatten( expression ); + return shouldWrap ? '(' + inner + ')' : inner; +} + +function flatten( expression ) { + switch ( expression.type ) { + // Terminal + case 'any': + return '.'; + case 'rule_ref': + return expression.name; + case 'literal': + return '"' + escape( expression.value ) + '"'; + case 'class': + return ( + '[' + ( expression.inverted ? '^' : '' ) + + expression.parts.map( ( part ) => + escape( Array.isArray( part ) ? part.join( '-' ) : part ) + ).join( '' ) + + ']' + ( expression.ignoreCase ? 'i' : '' ) + ); + + // Unary + case 'zero_or_more': + return flattenUnary( expression.expression ) + '*'; + case 'one_or_more': + return flattenUnary( expression.expression ) + '+'; + case 'optional': + return flattenUnary( expression.expression ) + '?'; + case 'simple_not': + return '!' + flattenUnary( expression.expression ); + + // Other groups + case 'sequence': + return expression.elements.map( flatten ).join( ' ' ); + case 'choice': + const sep = expression.isRuleTop ? '\n / ' : ' / '; + return expression.alternatives.map( flatten ).join( sep ); + case 'group': + return '(' + flatten( expression.expression ) + ')'; + case 'text': + // Avoid double parentheses + const inner = flatten( expression.expression ); + const shouldWrap = inner.indexOf( '(' ) !== 0; + return shouldWrap ? '$(' + inner + ')' : '$' + inner; + case 'action': + case 'labeled': + case 'named': + return flatten( expression.expression ); + + // Top-level formatting + case 'grammar': + return `
${ expression.rules.map( flatten ).join( '' ) }
`; + case 'rule': + expression.expression.isRuleTop = true; + const displayName = expression.expression.type === 'named' ? + expression.expression.name : ''; + return `
${ displayName }
` + + `
${ expression.name }
= ` + + `${ flatten( expression.expression ) }
`; + + default: + throw new Error( JSON.stringify( expression ) ); + } +} + +fs.writeFileSync( + path.join( __dirname, '..', 'docs', 'grammar.md' ), ` +# Block Grammar + +${ flatten( grammar ) } +` ); diff --git a/gb-src/bin/get-server-blocks.php b/gb-src/bin/get-server-blocks.php new file mode 100755 index 0000000000000..164fafd467db9 --- /dev/null +++ b/gb-src/bin/get-server-blocks.php @@ -0,0 +1,39 @@ +#!/usr/bin/env php + 1 && 'debug' === strtolower( $argv[1] ) ); + +// Hacks to get lib/client-assets.php to load. +define( 'ABSPATH', dirname( dirname( __FILE__ ) ) ); + +/** + * Hi, phpcs + */ +function add_action() {} + +/** + * Hi, phpcs + */ +function add_filter() {} + +/** + * Hi, phpcs + */ +function wp_add_inline_script() {} + +// Instead of loading script files, just show how they need to be loaded. +define( 'GUTENBERG_LIST_VENDOR_ASSETS', true ); + +require_once dirname( dirname( __FILE__ ) ) . '/lib/client-assets.php'; + +gutenberg_register_vendor_scripts(); diff --git a/gb-src/bin/packages/build-worker.js b/gb-src/bin/packages/build-worker.js new file mode 100644 index 0000000000000..7e3e636c013a1 --- /dev/null +++ b/gb-src/bin/packages/build-worker.js @@ -0,0 +1,159 @@ +/** + * External dependencies + */ +const { promisify } = require( 'util' ); +const fs = require( 'fs' ); +const path = require( 'path' ); +const babel = require( '@babel/core' ); +const makeDir = require( 'make-dir' ); +const sass = require( 'node-sass' ); +const postcss = require( 'postcss' ); + +/** + * Internal dependencies + */ +const getBabelConfig = require( './get-babel-config' ); + +/** + * Path to packages directory. + * + * @type {string} + */ +const PACKAGES_DIR = path.resolve( __dirname, '../../packages' ); + +/** + * Mapping of JavaScript environments to corresponding build output. + * + * @type {Object} + */ +const JS_ENVIRONMENTS = { + main: 'build', + module: 'build-module', +}; + +/** + * Promisified fs.readFile. + * + * @type {Function} + */ +const readFile = promisify( fs.readFile ); + +/** + * Promisified fs.writeFile. + * + * @type {Function} + */ +const writeFile = promisify( fs.writeFile ); + +/** + * Promisified sass.render. + * + * @type {Function} + */ +const renderSass = promisify( sass.render ); + +/** + * Get the package name for a specified file + * + * @param {string} file File name + * @return {string} Package name + */ +function getPackageName( file ) { + return path.relative( PACKAGES_DIR, file ).split( path.sep )[ 0 ]; +} + +/** + * Get Build Path for a specified file. + * + * @param {string} file File to build + * @param {string} buildFolder Output folder + * @return {string} Build path + */ +function getBuildPath( file, buildFolder ) { + const pkgName = getPackageName( file ); + const pkgSrcPath = path.resolve( PACKAGES_DIR, pkgName, 'src' ); + const pkgBuildPath = path.resolve( PACKAGES_DIR, pkgName, buildFolder ); + const relativeToSrcPath = path.relative( pkgSrcPath, file ); + return path.resolve( pkgBuildPath, relativeToSrcPath ); +} + +/** + * Object of build tasks per file extension. + * + * @type {Object} + */ +const BUILD_TASK_BY_EXTENSION = { + async '.scss'( file ) { + const outputFile = getBuildPath( file.replace( '.scss', '.css' ), 'build-style' ); + const outputFileRTL = getBuildPath( file.replace( '.scss', '-rtl.css' ), 'build-style' ); + + const [ , contents ] = await Promise.all( [ + makeDir( path.dirname( outputFile ) ), + readFile( file, 'utf8' ), + ] ); + + const builtSass = await renderSass( { + file, + includePaths: [ path.resolve( __dirname, '../../assets/stylesheets' ) ], + data: ( + [ + 'colors', + 'breakpoints', + 'variables', + 'mixins', + 'animations', + 'z-index', + ].map( ( imported ) => `@import "${ imported }";` ).join( ' ' ) + + contents + ), + } ); + + const result = await postcss( require( './post-css-config' ) ).process( builtSass.css, { + from: 'src/app.css', + to: 'dest/app.css', + } ); + + const resultRTL = await postcss( [ require( 'rtlcss' )() ] ).process( result.css, { + from: 'src/app.css', + to: 'dest/app.css', + } ); + + await Promise.all( [ + writeFile( outputFile, result.css ), + writeFile( outputFileRTL, resultRTL.css ), + ] ); + }, + + async '.js'( file ) { + for ( const [ environment, buildDir ] of Object.entries( JS_ENVIRONMENTS ) ) { + const destPath = getBuildPath( file, buildDir ); + const babelOptions = getBabelConfig( environment, file.replace( PACKAGES_DIR, '@wordpress' ) ); + + const [ , transformed ] = await Promise.all( [ + makeDir( path.dirname( destPath ) ), + babel.transformFileAsync( file, babelOptions ), + ] ); + + await Promise.all( [ + writeFile( destPath + '.map', JSON.stringify( transformed.map ) ), + writeFile( destPath, transformed.code + '\n//# sourceMappingURL=' + path.basename( destPath ) + '.map' ), + ] ); + } + }, +}; + +module.exports = async ( file, callback ) => { + const extension = path.extname( file ); + const task = BUILD_TASK_BY_EXTENSION[ extension ]; + + if ( ! task ) { + return; + } + + try { + await task( file ); + callback(); + } catch ( error ) { + callback( error ); + } +}; diff --git a/gb-src/bin/packages/build.js b/gb-src/bin/packages/build.js new file mode 100755 index 0000000000000..4c4883c152349 --- /dev/null +++ b/gb-src/bin/packages/build.js @@ -0,0 +1,190 @@ +/* eslint-disable no-console */ + +/** + * External dependencies + */ +const path = require( 'path' ); +const glob = require( 'fast-glob' ); +const ProgressBar = require( 'progress' ); +const workerFarm = require( 'worker-farm' ); +const { Readable, Transform } = require( 'stream' ); + +const files = process.argv.slice( 2 ); + +/** + * Path to packages directory. + * + * @type {string} + */ +const PACKAGES_DIR = path.resolve( __dirname, '../../packages' ); + +/** + * Get the package name for a specified file + * + * @param {string} file File name + * @return {string} Package name + */ +function getPackageName( file ) { + return path.relative( PACKAGES_DIR, file ).split( path.sep )[ 0 ]; +} + +/** + * Returns a stream transform which maps an individual stylesheet to its + * package entrypoint. Unlike JavaScript which uses an external bundler to + * efficiently manage rebuilds by entrypoints, stylesheets are rebuilt fresh + * in their entirety from the build script. + * + * @return {Transform} Stream transform instance. + */ +function createStyleEntryTransform() { + const packages = new Set; + + return new Transform( { + objectMode: true, + async transform( file, encoding, callback ) { + // Only stylesheets are subject to this transform. + if ( path.extname( file ) !== '.scss' ) { + this.push( file ); + callback(); + return; + } + + // Only operate once per package, assuming entries are common. + const packageName = getPackageName( file ); + if ( packages.has( packageName ) ) { + callback(); + return; + } + + packages.add( packageName ); + const entries = await glob( path.resolve( PACKAGES_DIR, packageName, 'src/*.scss' ) ); + entries.forEach( ( entry ) => this.push( entry ) ); + callback(); + }, + } ); +} + +/** + * Returns a stream transform which maps an individual block.json to the + * index.js that imports it. Presently, babel resolves the import of json + * files by inlining them as a JavaScript primitive in the importing file. + * This transform ensures the importing file is rebuilt. + * + * @return {Transform} Stream transform instance. + */ +function createBlockJsonEntryTransform() { + const blocks = new Set; + + return new Transform( { + objectMode: true, + async transform( file, encoding, callback ) { + const matches = /block-library[\/\\]src[\/\\](.*)[\/\\]block.json$/.exec( file ); + const blockName = matches ? matches[ 1 ] : undefined; + + // Only block.json files in the block-library folder are subject to this transform. + if ( ! blockName ) { + this.push( file ); + callback(); + return; + } + + // Only operate once per block, assuming entries are common. + if ( blockName && blocks.has( blockName ) ) { + callback(); + return; + } + + blocks.add( blockName ); + this.push( file.replace( 'block.json', 'index.js' ) ); + callback(); + }, + } ); +} + +let onFileComplete = () => {}; + +let stream; + +if ( files.length ) { + stream = new Readable( { encoding: 'utf8' } ); + files.forEach( ( file ) => stream.push( file ) ); + stream.push( null ); + stream = stream + .pipe( createStyleEntryTransform() ) + .pipe( createBlockJsonEntryTransform() ); +} else { + const bar = new ProgressBar( 'Build Progress: [:bar] :percent', { + width: 30, + incomplete: ' ', + total: 1, + } ); + + bar.tick( 0 ); + + stream = glob.stream( [ + `${ PACKAGES_DIR }/*/src/**/*.js`, + `${ PACKAGES_DIR }/*/src/*.scss`, + ], { + ignore: [ + `**/test/**`, + `**/__mocks__/**`, + ], + onlyFiles: true, + } ); + + // Pause to avoid data flow which would begin on the `data` event binding, + // but should wait until worker processing below. + // + // See: https://nodejs.org/api/stream.html#stream_two_reading_modes + stream + .pause() + .on( 'data', ( file ) => { + bar.total = files.push( file ); + } ); + + onFileComplete = () => { + bar.tick(); + }; +} + +const worker = workerFarm( require.resolve( './build-worker' ) ); + +let ended = false, + complete = 0; + +// End the worker farm once the stream has ended and every file has been +// processed. Completions must be counted even before the stream ends, since +// workers can finish files before the glob stream emits `end`. +const maybeEndWorkers = () => { + if ( ended && complete === files.length ) { + workerFarm.end( worker ); + } +}; + +stream + .on( 'data', ( file ) => worker( file, ( error ) => { + onFileComplete(); + + if ( error ) { + // If an error occurs, the process can't be ended immediately since + // other workers are likely pending. Optimally, it would end at the + // earliest opportunity (after the current round of workers has had + // the chance to complete), but this is not made directly possible + // through `worker-farm`. Instead, ensure at least that when the + // process does exit, it exits with a non-zero code to reflect the + // fact that an error had occurred. + process.exitCode = 1; + + console.error( error ); + } + + ++complete; + maybeEndWorkers(); + } ) ) + .on( 'end', () => { + ended = true; + maybeEndWorkers(); + } ) + .resume(); + +/* eslint-enable no-console */ diff --git a/gb-src/bin/packages/get-babel-config.js b/gb-src/bin/packages/get-babel-config.js new file mode 100644 index 0000000000000..d76e171d46b21 --- /dev/null +++ b/gb-src/bin/packages/get-babel-config.js @@ -0,0 +1,38 @@ +module.exports = function( environment = '', file ) { + /* + * Specific options to be passed using the caller config option: + * https://babeljs.io/docs/en/options#caller + * + * The caller options can only be 'boolean', 'string', or 'number' by design: + * https://github.com/babel/babel/blob/bd0c62dc0c30cf16a4d4ef0ddf21d386f673815c/packages/babel-core/src/config/validation/option-assertions.js#L122 + */ + const callerOpts = { caller: { + name: `WP_BUILD_${ environment.toUpperCase() }`, + } }; + switch ( environment ) { + case 'main': + // to be merged as a presetEnv option + callerOpts.caller.modules = 'commonjs'; + break; + case 'module': + // to be merged as a presetEnv option + callerOpts.caller.modules = false; + // to be merged as a pluginTransformRuntime option + callerOpts.caller.useESModules = true; + break; + default: + // preventing measure, this shouldn't happen ever + delete callerOpts.caller; + } + + // Sourcemaps options + const sourceMapsOpts = { + sourceMaps: true, + sourceFileName: file, + }; + + return { + ...callerOpts, + ...sourceMapsOpts, + }; +}; diff --git a/gb-src/bin/packages/get-packages.js b/gb-src/bin/packages/get-packages.js new file mode 100644 index 0000000000000..de0147435dad2 --- /dev/null +++ b/gb-src/bin/packages/get-packages.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { isEmpty, overEvery } = require( 'lodash' ); + +/** + * Absolute path to packages directory. + * + * @type {string} + */ +const PACKAGES_DIR = path.resolve( __dirname, '../../packages' ); + +/** + * Returns true if the given base file name for a file within the packages + * directory is itself a directory. + * + * @param {string} file Packages directory file. + * + * @return {boolean} Whether file is a directory. + */ +function isDirectory( file ) { + return fs.lstatSync( path.resolve( PACKAGES_DIR, file ) ).isDirectory(); +} + +/** + * Returns true if the given packages has "module" field. + * + * @param {string} file Packages directory file. + * + * @return {boolean} Whether file is a directory. + */ +function hasModuleField( file ) { + const { module } = require( path.resolve( PACKAGES_DIR, file, 'package.json' ) ); + + return ! isEmpty( module ); +} + +/** + * Filter predicate, returning true if the given base file name is to be + * included in the build. + * + * @param {string} pkg File base name to test. + * + * @return {boolean} Whether to include file in build. + */ +const filterPackages = overEvery( isDirectory, hasModuleField ); + +/** + * Returns the absolute path of all WordPress packages + * + * @return {Array} Package paths + */ +function getPackages() { + return fs + .readdirSync( PACKAGES_DIR ) + .filter( filterPackages ) + .map( ( file ) => path.resolve( PACKAGES_DIR, file ) ); +} + +module.exports = getPackages; diff --git a/gb-src/bin/packages/post-css-config.js b/gb-src/bin/packages/post-css-config.js new file mode 100644 index 0000000000000..3d7861f75044b --- /dev/null +++ b/gb-src/bin/packages/post-css-config.js @@ -0,0 +1,64 @@ +module.exports = [ + require( '@wordpress/postcss-themes' )( { + defaults: { + primary: '#0085ba', + secondary: '#11a0d2', + toggle: '#11a0d2', + button: '#007cba', + outlines: '#007cba', + }, + themes: { + 'admin-color-light': { + primary: '#0085ba', + secondary: '#c75726', + toggle: '#11a0d2', + button: '#0085ba', + outlines: '#007cba', + }, + 'admin-color-blue': { + primary: '#82b4cb', + secondary: '#d9ab59', + toggle: '#82b4cb', + button: '#d9ab59', + outlines: '#417e9B', + }, + 'admin-color-coffee': { + primary: '#c2a68c', + secondary: '#9fa47b', + toggle: '#c2a68c', + button: '#c2a68c', + outlines: '#59524c', + }, + 'admin-color-ectoplasm': { + primary: '#a7b656', + secondary: '#c77430', + toggle: '#a7b656', + button: '#a7b656', + outlines: '#523f6d', + }, + 'admin-color-midnight': { + primary: '#e14d43', + secondary: '#77a6b9', + toggle: '#77a6b9', + button: '#e14d43', + outlines: '#497b8d', + }, + 'admin-color-ocean': { + primary: '#a3b9a2', + secondary: '#a89d8a', + toggle: '#a3b9a2', + button: '#a3b9a2', + outlines: '#5e7d5e', + }, + 'admin-color-sunrise': { + primary: '#d1864a', + secondary: '#c8b03c', + toggle: '#c8b03c', + button: '#d1864a', + outlines: '#837425', + }, + }, + } ), + require( 'autoprefixer' )( { grid: true } ), + require( 'postcss-color-function' ), +]; diff --git a/gb-src/bin/packages/watch.js b/gb-src/bin/packages/watch.js new file mode 100644 index 0000000000000..1add26a5677a7 --- /dev/null +++ b/gb-src/bin/packages/watch.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const watch = require( 'node-watch' ); +const { spawn } = require( 'child_process' ); +const path = require( 'path' ); +const chalk = require( 'chalk' ); + +/** + * Internal dependencies + */ +const getPackages = require( './get-packages' ); + +const BUILD_SCRIPT = path.resolve( __dirname, './build.js' ); + +let filesToBuild = new Map(); + +const exists = ( filename ) => { + try { + return fs.statSync( filename ).isFile(); + } catch ( e ) {} + return false; +}; + +// Exclude test files including .js files inside of __tests__ or test folders +// and files with a suffix of .test or .spec (e.g. blocks.test.js), +// and deceitful source-like files, such as editor swap files. +const isSourceFile = ( filename ) => { + return ! [ /\/(__tests__|test)\/.+.js$/, /.\.(spec|test)\.js$/ ].some( ( regex ) => regex.test( filename ) ) && /.\.(js|json|scss)$/.test( filename ); +}; + +const rebuild = ( filename ) => filesToBuild.set( filename, true ); + +getPackages().forEach( ( p ) => { + const srcDir = path.resolve( p, 'src' ); + try { + fs.accessSync( srcDir, fs.F_OK ); + watch( path.resolve( p, 'src' ), { recursive: true }, ( event, filename ) => { + if ( ! isSourceFile( filename ) ) { + return; + } + + const filePath = path.resolve( srcDir, filename ); + if ( ( event === 'update' ) && exists( filePath ) ) { + // eslint-disable-next-line no-console + console.log( chalk.green( '->' ), `${ event }: ${ filename }` ); + rebuild( filePath ); + } else { + const buildFile = path.resolve( srcDir, '..', 'build', filename ); + try { + fs.unlinkSync( buildFile ); + process.stdout.write( + chalk.red( ' \u2022 ' ) + + path.relative( path.resolve( srcDir, '..', '..' ), buildFile ) + + ' (deleted)' + + '\n' + ); + } catch ( e ) {} + } + } ); + } catch ( e ) { + // doesn't exist + } +} ); + +setInterval( () => { + const files = Array.from( filesToBuild.keys() ); + if ( files.length ) { + filesToBuild = new Map(); + try { + spawn( 'node', [ BUILD_SCRIPT, ...files ], { stdio: [ 0, 1, 2 ] } ); + } catch ( e ) {} + } +}, 100 ); + +// eslint-disable-next-line no-console +console.log( chalk.red( '->' ), chalk.cyan( 'Watching for changes...' ) ); diff --git a/gb-src/bin/process-git-diff.js b/gb-src/bin/process-git-diff.js new file mode 100644 index 0000000000000..b80b117daa59e --- /dev/null +++ b/gb-src/bin/process-git-diff.js @@ -0,0 +1,34 @@ +// npm will introduce changes to a `package-lock.json` file for optional +// dependencies varying on environment. If the only changes are the +// addition of an "optional" flag in `package-lock.json` file from +// `git diff`: we ignore the results. +// +// See: https://github.com/npm/npm/issues/17722 + +// Example usage: +// +// git diff -U0 | xargs -0 node bin/process-git-diff + +// Example input: +// +// diff --git a/package-lock.json b/package-lock.json +// index e8c8a25dc..251af8689 100644 +// --- a/package-lock.json +// +++ b/package-lock.json +// @@ -14373 +14373,2 @@ +// - "dev": true +// + "dev": true, +// + "optional": true +// @@ -14648 +14649,2 @@ +// - "dev": true +// + "dev": true, +// + "optional": true + +const hasNonOptionalDiff = !! ( process.argv[ 2 ] || '' ) + // Strip individual diffs of optional-only. + .replace( /@@ .+ @@\n(-.+\n\+.+,\n)?\+.+\"optional\": true,?\n/gm, '' ) + // If no more line diffs remain after above, remove diff heading for file. + .replace( /diff --git a\/package-lock.json b\/package-lock.json\nindex \w+..\w+ \d+\n--- a\/package-lock.json\n\+\+\+ b\/package-lock.json\n(?!@@)/, '' ); + +// Exit with error code if, after replace, changes still exist. +process.exit( hasNonOptionalDiff ? 1 : 0 ); diff --git a/gb-src/bin/setup-local-env.sh b/gb-src/bin/setup-local-env.sh new file mode 100755 index 0000000000000..a427fb4e3f3ef --- /dev/null +++ b/gb-src/bin/setup-local-env.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Exit if any command fails. +set -e + +echo "Hi there! It looks like you're trying to use the old local environment setup script. This script has been retired, running \`npm run env install\` will setup a local environment for you, instead. + +Check out the documentation for more information: https://developer.wordpress.org/block-editor/contributors/develop/getting-started/" diff --git a/gb-src/bin/update-readmes.js b/gb-src/bin/update-readmes.js new file mode 100755 index 0000000000000..df420007de397 --- /dev/null +++ b/gb-src/bin/update-readmes.js @@ -0,0 +1,70 @@ +/** + * Node dependencies. + */ +const { join } = require( 'path' ); +const spawnSync = require( 'child_process' ).spawnSync; + +const packages = [ + 'a11y', + 'autop', + 'blob', + 'block-editor', + 'block-library', + 'block-serialization-default-parser', + 'blocks', + 'compose', + [ 'core-data', { + 'Autogenerated actions': 'src/actions.js', + 'Autogenerated selectors': 'src/selectors.js', + } ], + 'data', + 'data-controls', + 'date', + 'deprecated', + 'dom', + 'dom-ready', + 'e2e-test-utils', + 'edit-post', + 'element', + 'escape-html', + 'html-entities', + 'i18n', + 'keycodes', + 'plugins', + 'priority-queue', + 'redux-routine', + 'rich-text', + 'shortcode', + 'url', + 'viewport', + 'wordcount', +]; + +packages.forEach( ( entry ) => { + if ( ! Array.isArray( entry ) ) { + entry = [ entry, { 'Autogenerated API docs': 'src/index.js' } ]; + } + + const [ packageName, targets ] = entry; + + Object.entries( targets ).forEach( ( [ token, path ] ) => { + // Each target operates over the same file, so it needs to be processed synchronously, + // as to make sure the processes don't overwrite each other. + const { status, stderr } = spawnSync( + join( __dirname, '..', 'node_modules', '.bin', 'docgen' ), + [ + join( 'packages', packageName, path ), + `--output packages/${ packageName }/README.md`, + '--to-token', + `--use-token "${ token }"`, + '--ignore "/unstable|experimental/i"', + ], + { shell: true }, + ); + + if ( status !== 0 ) { + process.stderr.write( `${ packageName } ${ stderr.toString() }\n` ); + process.exit( 1 ); + } + } ); +} ); diff --git a/gb-src/composer.json b/gb-src/composer.json new file mode 100644 index 0000000000000..e869eb3ccc7ff --- /dev/null +++ b/gb-src/composer.json @@ -0,0 +1,26 @@ +{ + "name": "wordpress/gutenberg", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "description": "Prototyping since 1440. Development hub for the editor focus in core.", + "homepage": "https://wordpress.github.io/gutenberg/", + "keywords": [ + "gutenberg", "wordpress", "editor", "wp", "react", "javascript" + ], + "support": { + "issues": "https://github.com/WordPress/gutenberg/issues" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0", + "squizlabs/php_codesniffer": "^3.4.2", + "phpcompatibility/php-compatibility": "^9.2.0", + "wp-coding-standards/wpcs": "^2.1.1" + }, + "require": { + "composer/installers": "~1.0" + }, + "scripts": { + "format": "phpcbf --standard=phpcs.xml.dist --report-summary --report-source", + "lint": "phpcs --standard=phpcs.xml.dist" + } +} diff --git a/gb-src/composer.lock b/gb-src/composer.lock new file mode 100644 index 0000000000000..95af33e6daad8 --- /dev/null +++ b/gb-src/composer.lock @@ -0,0 +1,359 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "14e6f0898af5e07bd8ff8b2bc6172712", + "packages": [ + { + "name": "composer/installers", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "cfcca6b1b60bc4974324efb5783c13dca6932b5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/cfcca6b1b60bc4974324efb5783c13dca6932b5b", + "reference": "cfcca6b1b60bc4974324efb5783c13dca6932b5b", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0" + }, + "replace": { + "roundcube/plugin-installer": "*", + "shama/baton": "*" + }, + "require-dev": { + "composer/composer": "1.0.*@dev", + "phpunit/phpunit": "^4.8.36" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Craft", + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Thelia", + "WolfCMS", + "agl", + "aimeos", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "joomla", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "mediawiki", + "modulework", + "modx", + "moodle", + "osclass", + "phpbb", + "piwik", + "ppi", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "symfony", + "typo3", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "time": "2018-08-27T06:10:37+00:00" + } + ], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v0.5.0", + "source": { + "type": "git", + "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", + "reference": "e749410375ff6fb7a040a68878c656c2e610b132" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/e749410375ff6fb7a040a68878c656c2e610b132", + "reference": "e749410375ff6fb7a040a68878c656c2e610b132", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0", + "php": "^5.3|^7", + "squizlabs/php_codesniffer": "^2|^3" + }, + "require-dev": { + "composer/composer": "*", + "phpcompatibility/php-compatibility": "^9.0", + "sensiolabs/security-checker": "^4.1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "time": "2018-10-26T13:21:45+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "3db1bf1e28123fd574a4ae2e9a84072826d51b5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/3db1bf1e28123fd574a4ae2e9a84072826d51b5e", + "reference": "3db1bf1e28123fd574a4ae2e9a84072826d51b5e", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + }, + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "time": "2019-06-27T19:58:56+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", + "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards" + ], + "time": "2019-04-10T23:49:02+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "bd9c33152115e6741e3510ff7189605b35167908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/bd9c33152115e6741e3510ff7189605b35167908", + "reference": "bd9c33152115e6741e3510ff7189605b35167908", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.3.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0", + "phpcompatibility/php-compatibility": "^9.0", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "wordpress" + ], + "time": "2019-05-21T02:50:00+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/gb-src/docs/contributors/coding-guidelines.md b/gb-src/docs/contributors/coding-guidelines.md new file mode 100644 index 0000000000000..2e02dfeaa10f7 --- /dev/null +++ b/gb-src/docs/contributors/coding-guidelines.md @@ -0,0 +1,211 @@ +# Coding Guidelines + +This living document serves to prescribe coding guidelines specific to the Gutenberg project. Base coding guidelines follow the [WordPress Coding Standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/). The following sections outline additional patterns and conventions used in the Gutenberg project. + +## CSS + +### Naming + +To avoid class name collisions, class names **must** adhere to the following guidelines, which are loosely inspired by the [BEM (Block, Element, Modifier) methodology](https://en.bem.info/methodology/). + +All class names assigned to an element must be prefixed with the name of the package, followed by a dash and the name of the directory in which the component resides. Any descendent of the component's root element must append a dash-delimited descriptor, separated from the base by two consecutive underscores `__`. + +* Root element: `package-directory` +* Child elements: `package-directory__descriptor-foo-bar` + +The root element is considered to be the highest ancestor element returned by the default export in the `index.js`. Notably, if your folder contains multiple files, each with their own default exported component, only the element rendered by that of `index.js` can be considered the root. All others should be treated as descendents. + +**Example:** + +Consider the following component located at `packages/components/src/notice/index.js`: + +```jsx +export default function Notice( { children, onRemove } ) { + return ( +
+
+ { children } +
+ +
+ ); +} +``` + +Components may be assigned with class names that indicate states (for example, an "active" tab or an "opened" panel). These modifiers should be applied as a separate class name, prefixed as an adjective expression by `is-` (`is-active` or `is-opened`). In rare cases, you may encounter variations of the modifier prefix, usually to improve readability (`has-warning`). Because a modifier class name is not contextualized to a specific component, it should always be written in stylesheets as accompanying the component being modified (`.components-panel.is-opened`). + +**Example:** + +Consider again the Notices example. We may want to apply specific styling for dismissible notices. The [`classnames` package](https://www.npmjs.com/package/classnames) can be a helpful utility for conditionally applying modifier class names. + +```jsx +import classnames from 'classnames'; + +export default function Notice( { children, onRemove, isDismissible } ) { + const classes = classnames( 'components-notice', { + 'is-dismissible': isDismissible, + } ); + + return ( +
+ { /* ... */ } +
+ ); +} +``` + +A component's class name should **never** be used outside its own folder (with rare exceptions such as [`_z-index.scss`](https://github.com/WordPress/gutenberg/blob/master/assets/stylesheets/_z-index.scss)). If you need to inherit styles of another component in your own components, you should render an instance of that other component. At worst, you should duplicate the styles within your own component's stylesheet. This is intended to improve maintainability by treating individual components as the isolated abstract interface. + +#### SCSS File Naming Conventions for Blocks + +The build process will split SCSS from within the blocks library directory into two separate CSS files when Webpack runs. + +Styles placed in a `style.scss` file will be built into `blocks/build/style.css`, to load on the front end theme as well as in the editor. If you need additional styles specific to the block's display in the editor, add them to an `editor.scss`. + +Examples of styles that appear in both the theme and the editor include gallery columns and drop caps. + +## JavaScript + +### Imports + +In the Gutenberg project, we use [the ES2015 import syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) to enable us to create modular code with clear separations between code of a specific feature, code shared across distinct WordPress features, and third-party dependencies. + +These separations are identified by multi-line comments at the top of a file which imports code from another file or source. + +#### External Dependencies + +An external dependency is third-party code that is not maintained by WordPress contributors, but instead [included in WordPress as a default script](https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress) or referenced from an outside package manager like [npm](https://www.npmjs.com/). + +Example: + +```js +/** + * External dependencies + */ +import moment from 'moment'; +``` + +#### WordPress Dependencies + +To encourage reusability between features, our JavaScript is split into domain-specific modules which [`export`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) one or more functions or objects. In the Gutenberg project, we've distinguished these modules under top-level directories. Each module serve an independent purpose, and often code is shared between them. For example, in order to localize its text, editor code will need to include functions from the `i18n` module. + +Example: + +```js +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +``` + +#### Internal Dependencies + +Within a specific feature, code is organized into separate files and folders. As is the case with external and WordPress dependencies, you can bring this code into scope by using the `import` keyword. The main distinction here is that when importing internal files, you should use relative paths specific to top-level directory you're working in. + +Example: + +```js +/** + * Internal dependencies + */ +import VisualEditor from '../visual-editor'; +``` + +### Experimental and Unstable APIs + +Experimental and unstable APIs are temporary values exported from a module whose existence is either pending future revision or provides an immediate means to an end. + +_To External Consumers:_ + +**There is no support commitment for experimental and unstable APIs.** They can and will be removed or changed without advance warning, including as part of a minor or patch release. As an external consumer, you should avoid these APIs. + +_To Project Contributors:_ + +An experimental or unstable API is named as such to communicate instability of a function whose interface is not yet finalized. Aside from references within the code, these APIs should neither be documented nor mentioned in any CHANGELOG. They should effectively be considered to not exist from an external perspective. In most cases, they should only be exposed to satisfy requirements between packages maintained in this repository. + +An experimental or unstable function or object should be prefixed respectively using `__experimental` or `__unstable`. + +```js +export { __experimentalDoExcitingExperimentalAction } from './api'; +export { __unstableDoTerribleAwfulAction } from './api'; +``` + +- An **experimental API** is one which is planned for eventual public availability, but is subject to further experimentation, testing, and discussion. +- An **unstable API** is one which serves as a means to an end. It is not desired to ever be converted into a public API. + +In both cases, the API should be made stable or removed at the earliest opportunity. + +While an experimental API may often stabilize into a publicly-available API, there is no guarantee that it will. The conversion to a stable API will inherently be considered a breaking change by the mere fact that the function name must be changed to remove the `__experimental` prefix. + +### Objects + +When possible, use [shorthand notation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#New_notations_in_ECMAScript_2015) when defining object property values: + +```js +const a = 10; + +// Bad: +const object = { + a: a, + performAction: function() { + // ... + }, +}; + +// Good: +const object = { + a, + performAction() { + // ... + }, +}; +``` + +### Strings + +String literals should be declared with single-quotes *unless* the string itself contains a single-quote that would need to be escaped–in that case: use a double-quote. If the string contains a single-quote *and* a double-quote, you can use ES6 template strings to avoid escaping the quotes. + +**Note:** The single-quote character (`'`) should never be used in place of an apostrophe (`’`) for words like `it’s` or `haven’t` in user-facing strings. For test code it's still encouraged to use a real apostrophe. + +In general, avoid backslash-escaping quotes: + +```js +// Bad: +const name = "Matt"; +// Good: +const name = 'Matt'; + +// Bad: +const pet = 'Matt\'s dog'; +// Also bad (not using an apostrophe): +const pet = "Matt's dog"; +// Good: +const pet = 'Matt’s dog'; +// Also good: +const oddString = "She said 'This is odd.'"; +``` + +You should use ES6 Template Strings over string concatenation whenever possible: + +```js +const name = 'Stacey'; + +// Bad: +alert( 'My name is ' + name + '.' ); +// Good: +alert( `My name is ${ name }.` ); +``` + +## PHP + +We use +[`phpcs` (PHP\_CodeSniffer)](https://github.com/squizlabs/PHP_CodeSniffer) with the [WordPress Coding Standards ruleset](https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards) to run a lot of automated checks against all PHP code in this project. This ensures that we are consistent with WordPress PHP coding standards. + +The easiest way to use PHPCS is [local environment](/docs/contributors/getting-started.md#local-environment). Once that's installed, you can check your PHP by running `npm run lint-php`. + +If you prefer to install PHPCS locally, you should use `composer`. [Install `composer`](https://getcomposer.org/download/) on your computer, then run `composer install`. This will install `phpcs` and `WordPress-Coding-Standards` which you can then run via `composer lint`. diff --git a/gb-src/docs/contributors/copy-guide.md b/gb-src/docs/contributors/copy-guide.md new file mode 100644 index 0000000000000..a3178b69109b3 --- /dev/null +++ b/gb-src/docs/contributors/copy-guide.md @@ -0,0 +1,284 @@ +# Copy Guidelines + +## Longer Text +Guidelines for writing multi-line/step instructions or narrative introductions/orientation to pages or features. + +This will obviously vary quite a lot depending on the context, but here are some general tips: + +#### ONE: Contractions are your friends! +They’re more conversational, and a simple way to make text sound friendlier and less formal. (And they save a bit of space as well: a win-win.) + +#### TWO: Cut phrases that inflate your word count without actually adding meaning. +This happens frequently in two specific instances. First, when writing in the passive voice: + +> This block can be used to display single images. + +Any time you see phrases like “can be” or “is used”: halt. You’re writing in the passive voice. Try going active for a snappier (and shorter) sentence: + +> This block displays single images. + +Second, this happens when we hedge instead of making declarative statements: + +> The gallery block can help you display multiple images in an elegant layout. + +Does it or doesn’t it? We’re making this software: we’re allowed to be declarative about what it is and does: + +> The gallery block displays multiple images in an elegant layout. + +We also all do this a lot with the phrase “allows you to.” + +> Preformatted text allows you to keep your tabs and line breaks. + +Features don’t allow anyone to do anything; they’re just tools that do specific things to achieve an end. Just say what they do: + +> Preformatted text preserves your tabs and line breaks. + +The more direct sentences are almost always clearer. Scan your copy for the words “can,” “be,” “might,” “allows you to,” and “helps”—they’re the most common culprits, and looking for those words specifically is a way to locate phrasing you can tighten up. + +#### THREE: Beware of “simple,” “easy,” and “just.” +It is not for us to decide what is simple: it’s for the user to decide. If we say something is easy and the user doesn’t have an easy experience, it undermines their trust in us and what we’re building. Same goes for “just”—many of us know to avoid “simple,” but still use “just” all the time. “Just click here.” “Just enter your username.” It’s the same thing: it implies that something will be no big deal, but we can’t know what the user will find to be a big deal. + +It’s also safer and more helpful to be specific. “Easy” and “simple” are shorthand for explanations that we haven’t written; whenever you see them, take a minute to think about what they’re standing in for. Maybe “It’s easy to add a block by hitting ‘enter’” really means “You can add more content to the page without taking your hands off the keyboard.” Great! Say the specific thing instead of relying on “easy.” + +This isn’t to say that you should banish these words from your vocabulary. You might want to write a tooltip describing how the cover image block now requires less configuration, or an email about how we’re building a tool for quick creation of custom blocks, and you could legitimately say that the cover image block has been simplified or that we’re working to make custom block creation easier—there, the terms are descriptive and relative. But be on the lookout for ways you might be using (or overusing) them to make absolute claims that something is easy or simple, and use those as opportunities to be more specific and clear. + +#### FOUR: Look out for “we.” +Any time text or instructions uses “we” a lot, it means the focus of the text is on the people behind the software and not the people using the software. Sometimes that’s what you actually want—but it’s usually not. The focus should typically be on the user, what they need, and how they benefit rather than “what we did” or “what we want.” + +We’re the only ones that care about what we did or want; the user just wants software that works. If you see a lot of “we”s, think about whether you should reframe what you’re writing to focus on the benefits to and successes of the user. + +## Bulleted Lists +Guidelines for (duh) writing bulleted lists. + +#### ONE: Keep sentence structures parallel across all bullets. +Parallel structure makes lists easier to read quickly—their predictability takes some cognitive load off the reader. + +GOOD: +> What can you do with this block? Lots of things! +> * Add a quote. +> * Highlight a link. +> * Display multiple images. +> * Create a bulleted list. + +Every bullet is a full sentence, and ends with a period. (If your list is a bunch of one- or two-word items, those can often just turn into a single regular sentence—easier to read, and space-saving.) Every line begins with a verb that tells the user what the block can do. The subject of the sentence is always the user. + +A user can absorb this list quickly because once they read the first item, they understand how to read the rest and know what information they’ll find. + +LESS GOOD: +> What can you do with this block? Lots of things! +> * You can add a quote. +> * Highlighting a link you love. +> * It displays multiple images. Nice for galleries! +> * Bulleted lists + +Here, every line has different phrasing (some start with a verb, some with a noun) and the subject of the sentence changes (sometimes it’s you, sometimes it’s the block). Some lines have added description, some don't. There’s an incomplete sentence, and punctuation is inconsistent. + +Reading this list takes more work because the reader has to parse each bullet anew. They can’t assume each bullet will contain similar information. + +Note: this doesn't mean every bullet has to be super short and start with an action verb! “Predictable” doesn’t have to mean “simple.” It just means that each bullet should have the same sentence structure. This list would also be fine: + +> What can you do with this block? Lots of things! +> * Try adding a quote. Sometimes someone else said things best! +> * Use it to highlight a link you love—sharing links is the currency of the internet. +> * Create a gallery that displays multiple images, and show off your best photos. + +Here, each bullet starts with a more user-focused verb and includes a piece of supplemental information for more interest. The punctuation varies a bit, which keeps the lines from feeling too formulaic, but since the basic structure of each is the same, they remain easy to read. + +#### TWO: When in doubt, start with a verb. (But not always the same verb.) +Do you have to start with a verb? No. But if you’re at a loss, you usually can’t go wrong with a verb (especially since bulleted lists are often describing a series of actions or possible actions). + +In a simple list that’s meant to be purely instructional (e.g., in UI copy where you just need the user to make a decision), it might be fine to start every bullet with the same verb: + +> To continue, choose an action: +> * Add a simple text block. +> * Add a pullquote block. +> * Add an image block. + +If your list is more persuasive (e.g., trying to convince someone to use a feature by listing its benefits) or includes multi-step instructions, you’ll want to vary your verbs to keep the reader engaged with more interesting language, as in the example above: + +>What can you do with this block? Lots of things! +>* Try adding a quote. Sometimes someone else said things best! +>* Use it to highlight a link you love—sharing links is the currency of the internet. +>* Create a gallery that displays multiple images, and show off your best photos. + +These aren’t hard-and-fast rules—you might choose the use the same verb in a persuasive list to be more focused and powerful, for example. But they’re good starting places for solid lists. + +#### THREE: When something's clearly a list, you don't have to tell us it's a list. + +GOOD: +> What can you do with this block? Lots of things! +> * Add a quote. +> * Highlight a link you love. +> * Display multiple images. + +LESS GOOD: +> What can you do with this block? Lots of things! Here are some examples of ways you can use it. +> * You can add a quote. +> * Highlighting a link you love. +> * It displays multiple images. Nice for galleries! + +Find the balance between being as clear as possible and trusting a user. On one hand, we know that people don’t always read instructions; on the other, redundancy can make the user feel like we think they’re stupid. + +#### FOUR: Bold is sometimes your friend. +Use it to focus readers on the key information in a bulleted list. This is especially useful when your bullets include some supplemental but ultimately secondary information. + +“Key information” is, well, key: bold draws the eye, so stick to the most vital piece of information in a given bullet: + +>What can you do with this block? Lots of things! +> * Try adding a **quote**. Sometimes someone else said things best! +> * Use it to highlight a **link** you love—sharing links is the currency of the internet. +> * Create a **gallery** that displays multiple images, and show off your best photos. + +On the flipside, bolding too many things creates visual confusion: + +> **What can you do with this block?** Lots of things! +> * Try adding a **quote**. Sometimes someone else said things best! +> * Use it to highlight a **link** you love—sharing **links** is the currency of the internet. +> * Create a **gallery** that displays **multiple images**, and show off your best **photos**. + +When lists are short and basic, don't bother—bolding just adds busy-ness. + +> What can you do with this block? Lots of things! +> * Add a **quote**. +> * Highlight a **link**. +> * Display multiple **images**. + +The lack of words creates its own focus; you don't have to add any more. + +## UI Descriptions +Guidelines for writing one-line feature descriptions, or short descriptions to clarify options. + +#### ONE: Clarity above all! +If the user doesn't understand what using a particular option will result in, it doesn't matter how clever your pun is. Wordplay and idioms are frequently unclear, and easily misunderstood. If you use them at all, they should be as supplemental information— never to explain the main idea—and they should be something you’re fairly certain will be understandable to a pretty wide range of people. + +#### TWO: Refer back to section one, and look out for those bulk-adding phrases. +Active voice is typically the better way to go, and cutting out the bulky phrasing is particularly important when you’ve got limited space and you need people to be able to make decisions and act. Often you can shorten a UI instruction phrase to be both shorter and clearer: + +> When you click X, Y happens. + +vs. + +> Click X to do Y. + +While it can feel like adding the extra words helps walk a user through the product, the extra words just serve to obscure the point being communicated: + +> When you click the “settings” button, the pop-up will display the advanced settings that are available. + +vs. + +> Click “settings” to access the advanced settings. + +Similar phrases are “Once you do X…” or “If you want to do X…” Sometimes there are decision points where “If you want to do X…” is entirely appropriate because there are different paths the user can take based on their goal. But, we often use it to mean “Here is a thing you can do,” which you can express more simply as: “To do X…” + +#### THREE: Be specific. +When an action depends on the user having completed some prior action, be specific about what’s required and what happens next. We often default to “when you’re ready.” +Ready for what? Be specific about whatever the prerequisites are. + +“When you’re ready” can mean: + +* When you want to add another block” +* When you’re satisfied with your post” +* After you’ve finished proofreading your post” +* When you’d like to add a featured image” +* After you’ve configured all the settings” + +And when something means everything, it actually means nothing. The more specific instructions are, the more useful they are, and the more trust the person following them will have in the product. + +#### FOUR: This is still writing. It should have personality and interest. +Clarity above all, yes, and space is often limited here—but UI text can still be interesting to read. + +Single lines of description can still be complete sentences. + +> List. Numbered or bulleted. + +vs. + +> Add a list, either numbered or bulleted. + +You can still use contractions. + +> Add a list. We will provide formatting options. + +vs. + +> Add a bulleted list—we’ll give you some formatting options. + +You can still use punctuation—em dashes, colons, semicolons—to control the flow of your words, link ideas, and create pauses. + +> List. Numbered or bulleted. + +vs. + +> Add a list—numbered or bulleted. Your choice! + +You can still try to avoid jargon in favor of plain language. + +> Add unordered or ordered list. + +vs. + +> Add a list, either numbered or bulleted. + +(And because it bears repeating: no wordplay, please! “Personality” can—and in UI instructions, should—be subtle. We’re talking about text that sounds like it was said by a human being, not forced attempts at whimsy.) + +#### FIVE: Pay attention to capitalization. +When it comes to headlines and subheads, there are two ways to capitalize: + +In Title Case, the First Letter of Almost Every Word Is Capitalized + +In sentence case, only the first letter of the line is capitalized + +Feature names and dashboard sections typically use title case (think “Site Stats” or “Recently Published”), whereas feature labels typically use sentence case (like “Show buttons on” or “Comment Likes are,” where “Likes” is capitalized because it’s the feature name, but the overall label is using sentence case). + +When you’re looking at a full page of UI copy, make sure you’re being consistent across all of it, and that all similar kinds of copy—headlines, tooltips, buttons, etc.—are using the same case. + +## Error Messaging +Guidelines for writing error messages that are understandable and useful. + +#### ONE: Don’t ignore voice/tone in error messaging—they communicate a lot. +Voice and tone can say as much as the individual words themselves. Error messages have to convey a significant amount of information and usually need to be fairly short, but try not to sacrifice tone, or to go too far in either a negative or positive direction. + +Let’s say someone’s trying to publish a post, but their user role doesn’t allow them to do that. Here are some ways we could—but should not—communicate that: + +> Your user role is incorrect. + +Here, we sound distant and uncaring. + +> Stop! You do not have permission to do this. + +Here, we sound unnecessarily alarmist and stern. + +> Oopsie, we can’t let you do that! + +Here, we sound too cute. + +We can stay direct, positive, and friendly, even in error messages. How? With tips two through four! + +#### TWO: Whenever possible, offer a path to resolution. +A good error message doesn’t just alert someone to the fact that something is wrong. + +> Your user role is incorrect. + +Okay, fine. Why does that matter? What do I do about it? How does this message help me? I need to know why my user role matters, and how to get the role I need so I can complete the action I want to complete. An error message that doesn’t provide any instruction leaves the user without a path forward; they can’t avoid repeating the action that led to the error if we don’t tell them now. + +#### THREE: Don’t lean on jargon to cut down on words when space is tight. + +> Your user role is incorrect. Contact a site administrator. + +Maybe we’re getting somewhere here: now I know there’s something I can do about things, which is good. + +Then again, maybe we’re not: I still don’t know what my role is, or why it matters. Also, now I’m also not sure what a site administrator is, who mine is, or how to contact them. + +All the information in this error message is technically entirely correct, but that doesn’t mean it communicates anything useful. If the goal is understanding and resolution, technical accuracy doesn’t always get us there. + +“Your account does not have permission to publish posts” doesn’t use the language of the user roles UI, but it does explain what’s gone wrong and I can understand it even if I don’t know what a user role is. And since I understand, I’m also better placed to understand the resolution, even if the message ended here: I can see that I need to get permission. + +Consistency with existing UI language is great, but not when it gets in the way of understanding. + +#### FOUR: Don’t assume people understand where the error came from. + +> Your user role is incorrect. + +It might seem obvious to us that the user got this message when they tried to publish something or change a setting that they don’t have permission for. It might not be so obvious to the user: people click around a lot, especially when we’re unsure how to do something, and we don’t always remember what page or setting we were just looking at (or why!). + +A good error message also includes some context that orients the user. “Your account does not have permission to publish posts” reminds them that they were trying to publish a post, and that that’s the particular stumbling block that caused the error. diff --git a/gb-src/docs/contributors/design.md b/gb-src/docs/contributors/design.md new file mode 100644 index 0000000000000..d71221003d378 --- /dev/null +++ b/gb-src/docs/contributors/design.md @@ -0,0 +1,66 @@ +# Design Principles & Vision + +This is a living document that outlines the design principles and patterns of the editor interface. Its aim is to explain the background of the design, inform future improvements, and help people design great blocks. + +## Principles + +![Gutenberg Logo](https://cldup.com/J2MgjuShPv-3000x3000.png) + +### Goal of Gutenberg + +Gutenberg's all-encompassing goal is a post- and page-building experience that makes it easy to create rich layouts. The block editor was the first product launched following this methodology for working with content. + +From the [kickoff post](https://make.wordpress.org/core/2017/01/04/focus-tech-and-design-leads/): + +> The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery. + +We can extract a few key principles from this: + +- **Authoring rich posts is a key strength of WordPress.** +- **Blocks will unify features and types of interaction under a single interface.** Users shouldn’t have to write shortcodes, custom HTML, or paste URLs to embed. Users only need to learn how the block works in order to use all of its features. +- **Make core features more discoverable**, reducing hard-to-find “Mystery meat.” WordPress supports a large number of blocks and 30+ embeds. Let’s increase their visibility. + +### Why + +One thing that sets WordPress apart from other systems is that it allows users to create as rich a post layout as they can imagine — as long as they know HTML and CSS and build a custom theme. + +Gutenberg reshapes the editor into a tool that allows users write rich posts and build beautiful layouts in a few clicks — no technical knowledge needed. WordPress will become a powerful and flexible content tool that’s accessible to all. + +### Vision + +Gutenberg wants to make it easier to author rich content. This means ensuring good defaults, bundling advanced layout options into blocks, and making the most important actions immediately available. Authoring content with WordPress should be accessible to anyone. + +**Everything on a WordPress website becomes a block:** text, images, galleries, widgets, shortcodes, and even chunks of custom HTML, whether added by plugins or otherwise. Users will only have to learn a single interface —— the block interface. + +**All blocks are created equal.** They all live in the same inserter interface. Recency, search, tabs, and grouping ensure that the most-used blocks are within easy reach. + +**Drag-and-drop is secondary.** For greater accessibility and platform compatibility, drag-and-drop interactions are used as an additive enhancement on top of explicit actions like click, tab, and space. + +**Placeholders are key.** If a block can have a neutral placeholder state, it should. An image placeholder block shows a button to open the media library, and a text placeholder block shows a writing prompt. By embracing placeholders we can predefine editable layouts, so all users have to do is fill in the blanks. + +**Direct manipulation is intuitive.** The block interface allows users to manipulate content directly on the page. Plugin and theme authors will support and extend this experience by building their own custom blocks. + +**Code editing shouldn't be necessary for customization.** Customizing traditionally required complicated markup, and complicated markup is easy to break. With Gutenberg, customizing becomes more intuitive — and safer. A developer will be able to provide custom blocks that directly render portions of a layout (a three column grid of features, for instance) and clearly specify what can be directly edited by the user. That means the user can update text, swap images, reduce the number of columns, without having to ask a developer, or worrying about breaking things. + +### Future Opportunities + +The initial phase of Gutenberg as described in the kickoff goal is primarily limited to the content area (specifically `post_content`) of posts and pages. Within those confines, we are embracing the web as a vertical river of content by appending blocks sequentially, then adding layout options to each block. + +That said, there isn’t any fixed limit to the kind of layouts Gutenberg will be able to create. It’s very possible for Gutenberg to grow beyond the confines of post and page content, to include the whole page — one could think of a theme template as a comma-separated list of blocks, like this: + +```js +{ + 'theme/header', + 'theme/sidebar', + 'core/content' { + 'core/cover-image', + 'theme/author-card', + 'core/text', + }, + 'theme/footer', +} +``` + +Every block nested inside the content block would be _rearrangeable_. Every block would be _editable_. Every block would use the same API, and both the editor and the theme would load the same `style.css` file directly. In the end, both the editor/page builder and theme/front-end would appear near-identical, allowing for a true WYSIWYG experience. + +This concept is speculative, but it’s one direction Gutenberg could go in the future. diff --git a/gb-src/docs/contributors/develop.md b/gb-src/docs/contributors/develop.md new file mode 100644 index 0000000000000..7b8ad300ee316 --- /dev/null +++ b/gb-src/docs/contributors/develop.md @@ -0,0 +1,15 @@ +# Developer Contributions + +Please also see [CONTRIBUTING.md](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md) for general information about contributions to the Gutenberg repository. + +The following resources offer additional information for developers who wish to contribute to Gutenberg: + +* [Getting Started](/docs/contributors/getting-started.md). +* [Git Workflow](/docs/contributors/git-workflow.md). +* [Coding Guidelines](/docs/contributors/coding-guidelines.md) outline additional patterns and conventions used in the Gutenberg project. +* [Testing Overview](/docs/contributors/testing-overview.md) for PHP and JavaScript development in Gutenberg. +* [Gutenberg Block Grammar](/docs/contributors/grammar.md). +* [Scripts](/docs/contributors/scripts.md) - a list of vendor and internal scripts available to plugin developers. +* [Managing Packages](/docs/contributors/managing-packages.md). +* [Gutenberg Release Process](/docs/contributors/release.md) - a checklist for the different type of releases for Gutenberg project. +* [Localizing Gutenberg Plugin](/docs/contributors/localizing.md) - a guide on how to translate Gutenberg in your locale or language. diff --git a/gb-src/docs/contributors/document.md b/gb-src/docs/contributors/document.md new file mode 100644 index 0000000000000..43efc49c3db17 --- /dev/null +++ b/gb-src/docs/contributors/document.md @@ -0,0 +1,34 @@ +# Documentation Contributions + +Documentation for Gutenberg is maintained in the `/docs/` directory in the same Gutenberg Github repository. The docs are published every 15 minutes to the [Block Editor Handbook site](https://developer.wordpress.org/block-editor/). + +## New Document + +To add a new documentation page: + +1. Create a Markdown file in the [docs](https://github.com/WordPress/gutenberg/tree/master/docs) folder +2. Add item to the [toc.json](https://github.com/WordPress/gutenberg/blob/master/docs/toc.json) hierarchy +3. Update manifest.json by running `npm run docs:build` +4. Commit manifest.json with other files updated + +## Using Links + +It's very likely that at some point you will want to link to other documentation pages. It's worth emphasizing that all documents can be browsed in different contexts: + +- Block Editor Handbook +- GitHub website +- npm website + +To create links that work in all contexts, you should use absolute path links without the `https://github.com/WordPress/gutenberg` prefix. You can reference files using the following patterns: + +- `/docs/*.md` +- `/packages/*/README.md` +- `/packages/components/src/**/README.md` + +This way they will be properly handled in all three aforementioned contexts. + +## Resources + +* [Copy Guidelines](/docs/contributors/copy-guide.md) for writing instructions, documentations, or other contributions to Gutenberg project. + +* [Tone and Voice Guide](https://make.wordpress.org/docs/handbook/documentation-team-handbook/tone-and-voice-guide/) from WordPress Documentation. diff --git a/gb-src/docs/contributors/getting-started.md b/gb-src/docs/contributors/getting-started.md new file mode 100644 index 0000000000000..3105b6a0aa15d --- /dev/null +++ b/gb-src/docs/contributors/getting-started.md @@ -0,0 +1,70 @@ +# Getting Started + +Gutenberg is a Node.js-based project, built primarily in JavaScript. + +The first step is to install a recent version of Node. The easiest way (on MacOS, Linux, or Windows 10 with the Linux Subsystem) is by installing and running [nvm]. Once `nvm` is installed, you can install the correct version of Node by running `nvm install` in the Gutenberg directory. + +Once you have Node installed, run these scripts: + +``` +npm install +npm run build +``` + +This will build Gutenberg, ready to be used as a WordPress plugin! + +If you don't have a local WordPress environment to load Gutenberg in, we can help get that up and running, too. + +## Local Environment + +The quickest way to get up and running is to use the provided Docker setup. If you don't already have it, you'll need to install Docker by following their instructions for [Windows 10 Pro](https://docs.docker.com/docker-for-windows/install/), [all other version of Windows](https://docs.docker.com/toolbox/toolbox_install_windows/), [macOS](https://docs.docker.com/docker-for-mac/install/), or [Linux](https://docs.docker.com/v17.12/install/linux/docker-ce/ubuntu/#install-using-the-convenience-script). + +Once Docker is installed and running, run this script to install WordPress, and build your local environment: + +``` +npm run env install +``` + +WordPress will be installed in the `wordpress` directory, if you need to access WordPress core files directly, you can find them there. + +If you already have WordPress checked out in a different directory, you can use that installation, instead, by running these commands: + +``` +export WP_DEVELOP_DIR=/path/to/wordpress-develop +npm run env connect +``` + +This will use WordPress' own local environment, and mount your Gutenberg directory as a volume there. + +In Windows, you can set the `WP_DEVELOP_DIR` environment variable using the appropriate method for your shell: + + CMD: set WP_DEVELOP_DIR=/path/to/wordpress-develop + PowerShell: $env:WP_DEVELOP_DIR = "/path/to/wordpress-develop" + +The WordPress installation should be available at `http://localhost:8889` (**Username**: `admin`, **Password**: `password`). +If this port is in use, you can override it using the `LOCAL_PORT` environment variable. For example, `export LOCAL_PORT=7777` will change the URL to `http://localhost:7777` . If you're running [e2e tests](/docs/contributors/testing-overview.md#end-to-end-testing), this change will be used correctly. + +To bring down this local WordPress instance later run `npm run env stop`. To bring it back up again, run `npm run env start`. + +WordPress comes with specific [debug systems](https://wordpress.org/support/article/debugging-in-wordpress/) designed to simplify the process as well as standardize code across core, plugins and themes. It is possible to use environment variables (`LOCAL_WP_DEBUG` and `LOCAL_SCRIPT_DEBUG`) to update a site's configuration constants located in `wp-config.php` file. Both flags can be disabled at any time by running the following command: +``` +LOCAL_SCRIPT_DEBUG=false LOCAL_WP_DEBUG=false npm run env install +``` +By default, both flags will be set to `true`. + +## On A Remote Server + +Open a terminal (or if on Windows, a command prompt) and navigate to the repository you cloned. Now type `npm install` to get the dependencies all set up. Once that finishes, you can type `npm run build`. You can now upload the entire repository to your `wp-content/plugins` directory on your web server and activate the plugin from the WordPress admin. + +You can also type `npm run package-plugin` which will run the two commands above and create a zip file automatically for you which you can use to install Gutenberg through the WordPress admin. + +[npm]: https://www.npmjs.com/ +[nvm]: https://github.com/creationix/nvm + +## Playground + +The Gutenberg repository also includes a static Gutenberg playground that allows testing and developing in a WordPress-agnostic context. This is very helpful for developing reusable components and trying generic JavaScript modules without any backend dependency. + +You can launch the playground by running `npm run playground:start` locally. The playground should be available on [http://localhost:1234](http://localhost:1234). + +You can also test the playground version of the current master branch on GitHub Pages: [https://wordpress.github.io/gutenberg/](https://wordpress.github.io/gutenberg/) diff --git a/gb-src/docs/contributors/git-workflow.md b/gb-src/docs/contributors/git-workflow.md new file mode 100644 index 0000000000000..7f1499bc7de34 --- /dev/null +++ b/gb-src/docs/contributors/git-workflow.md @@ -0,0 +1,65 @@ +# Git Workflow + +A good workflow for new contributors to follow is listed below: +- Fork Gutenberg repository +- Clone forked repository +- Create a new branch +- Make code changes +- Commit code changes within the newly created branch +- Push branch to forked repository +- Submit Pull Request to Gutenberg repository + +Ideally name your branches with prefixes and descriptions, like this: `[type]/[change]`. A good prefix would be: + +- `add/` = add a new feature +- `try/` = experimental feature, "tentatively add" +- `update/` = update an existing feature + +For example, `add/gallery-block` means you're working on adding a new gallery block. + +You can pick among all the tickets, or some of the ones labelled Good First Issue. + +The workflow is documented in greater detail in the [repository management](/docs/contributors/repository-management.md) document. + +## Keeping Your Branch Up To Date + +When many different people are working on a project simultaneously, pull requests can go stale quickly. A "stale" pull request is one that is no longer up to date with the main line of development, and it needs to be updated before it can be merged into the project. + +There are two ways to do this: merging and rebasing. In Gutenberg, the recommendation is to rebase. Rebasing means rewriting your changes as if they're happening on top of the main line of development. This ensures the commit history is always clean and linear. Rebasing can be performed as many times as needed while you're working on a pull request. **Do share your work early on** by opening a pull request and keeping your history rebase as you progress. + +The main line of development is known as the `master` branch. If you have a pull-request branch that cannot be merged into `master` due to a conflict (this can happen for long-running pull requests), then in the course of rebasing you'll have to manually resolve any conflicts in your local copy. Learn more in [section _Perform a rebase_](https://github.com/edx/edx-platform/wiki/How-to-Rebase-a-Pull-Request#perform-a-rebase) of _How to Rebase a Pull Request_. + +Once you have resolved any conflicts locally you can update the pull request with `git push --force-with-lease`. Using the `--force-with-lease` parameter is important to guarantee that you don't accidentally overwrite someone else's work. + +To sum it up, you need to fetch any new changes in the repository, rebase your branch on top of `master`, and push the result back to the repository. These are the corresponding commands: + +```sh +git fetch +git rebase master +git push --force-with-lease your-branch-name +``` + +## Keeping Your Fork Up To Date + +Working on pull request starts with forking the Gutenberg repository, your separate working copy. Which can easily go out of sync as new pull requests are merged into the main repository. Here your working repository is a `fork` and the main Gutenberg repository is `upstream`. When working on new pull request you should always update your fork before you do `git checkout -b my-new-branch` to work on a feature or fix. + +To sync your fork you need to fetch the upstream changes and merge them into your fork. These are the corresponding commands: + +``` sh +git fetch upstream +git checkout master +git merge upstream/master +``` + +This will update you local copy to update your fork on github push your changes + +``` +git push +``` + +The above commands will update your `master` branch from _upstream_. To update any other branch replace `master` with the respective branch name. + + +## References +- https://git-scm.com/book/en/v2 +- https://help.github.com/categories/collaborating-with-issues-and-pull-requests/ diff --git a/gb-src/docs/contributors/grammar.md b/gb-src/docs/contributors/grammar.md new file mode 100644 index 0000000000000..7d7e9bf73b8c0 --- /dev/null +++ b/gb-src/docs/contributors/grammar.md @@ -0,0 +1,6 @@ + +# Block Grammar + +
Block_List
= $(!Block .)* (Block $(!Block .)*)* $(.*)
Block
= Block_Void + / Block_Balanced
Block_Void
= "<!--" __ "wp:" Block_Name __ (Block_Attributes __)? "/-->"
Block_Balanced
= Block_Start (Block / $(!Block_End .))* Block_End
Block_Start
= "<!--" __ "wp:" Block_Name __ (Block_Attributes __)? "-->"
Block_End
= "<!--" __ "/wp:" Block_Name __ "-->"
Block_Name
= Namespaced_Block_Name + / Core_Block_Name
Namespaced_Block_Name
= $(Block_Name_Part "/" Block_Name_Part)
Core_Block_Name
= $(Block_Name_Part)
Block_Name_Part
= $([a-z] [a-z0-9_-]*)
JSON-encoded attributes embedded in a block's opening comment
Block_Attributes
= $("{" (!("}" __ "" "/"? "-->") .)* "}")
__
= [ \t\r\n]+
diff --git a/gb-src/docs/contributors/history.md b/gb-src/docs/contributors/history.md new file mode 100644 index 0000000000000..a0f05a9cb446a --- /dev/null +++ b/gb-src/docs/contributors/history.md @@ -0,0 +1,19 @@ +# History + +## Survey +There was a survey done: [https://make.wordpress.org/core/2017/04/07/editor-experience-survey-results/](https://make.wordpress.org/core/2017/04/07/editor-experience-survey-results/) + +## Inspiration +This includes a list of historical articles and influences on the Gutenberg project. + +- LivingDocs: [https://beta.livingdocs.io/articles](https://beta.livingdocs.io/articles) +- Parrot: [https://intenseminimalism.com/2017/parrot-an-integrated-site-builder-and-editor-concept-for-wordpress/](https://intenseminimalism.com/2017/parrot-an-integrated-site-builder-and-editor-concept-for-wordpress/) +- Apple Keynote +- Slack +- Google Sites v2 + +## Blog posts by the team + +- Gutenberg tag on make/core: updates and much more: [https://make.wordpress.org/core/tag/gutenberg/](https://make.wordpress.org/core/tag/gutenberg/) +- Suggested revised timeline: [https://make.wordpress.org/core/2017/08/11/revised-suggested-roadmap-for-gutenberg-and-customization/](https://make.wordpress.org/core/2017/08/11/revised-suggested-roadmap-for-gutenberg-and-customization/) +- Discovering Gutenberg: [https://make.wordpress.org/core/2017/08/08/discovering-gutenberg-and-next-steps/](https://make.wordpress.org/core/2017/08/08/discovering-gutenberg-and-next-steps/) diff --git a/gb-src/docs/contributors/localizing.md b/gb-src/docs/contributors/localizing.md new file mode 100644 index 0000000000000..66d8df9568296 --- /dev/null +++ b/gb-src/docs/contributors/localizing.md @@ -0,0 +1,9 @@ +# Localizing Gutenberg Plugin + +To translate Gutenberg in your locale or language, [select your locale here](https://translate.wordpress.org/projects/wp-plugins/gutenberg) and translate *Development* (which contains the plugin's string) and/or *Development Readme* (please translate what you see in the Details tab of the [plugin page](https://wordpress.org/plugins/gutenberg/)). + +A Global Translation Editor (GTE) or Project Translation Editor (PTE) with suitable rights will process your translations in due time. + +Language packs are automatically generated once 95% of the plugin's strings are translated and approved for a locale. + +The inclusion of Gutenberg into WordPress core means that more than 51% of WordPress installations running a translated WordPress installation have Gutenberg's translated strings compiled into the core language pack as well. diff --git a/gb-src/docs/contributors/managing-packages.md b/gb-src/docs/contributors/managing-packages.md new file mode 100644 index 0000000000000..000a74026469f --- /dev/null +++ b/gb-src/docs/contributors/managing-packages.md @@ -0,0 +1,8 @@ +# Managing Packages + +This repository uses [lerna] to manage Gutenberg modules and publish them as packages to [npm]. This enforces certain steps in the workflow which are described in details in [packages](/packages/README.md) documentation. + +Maintaining dozens of npm packages is difficult—it can be tough to keep track of changes. That's why we use `CHANGELOG.md` files for each package to simplify the release process. As a contributor you should add an entry to the aforementioned file each time you contribute adding production code as described in [Maintaining Changelogs](/packages/README.md#maintaining-changelogs) section. + +[lerna]: https://lernajs.io/ +[npm]: https://www.npmjs.com/ diff --git a/gb-src/docs/contributors/outreach.md b/gb-src/docs/contributors/outreach.md new file mode 100644 index 0000000000000..208a1eaf707e1 --- /dev/null +++ b/gb-src/docs/contributors/outreach.md @@ -0,0 +1,65 @@ +# Outreach + +This includes articles, talks, demos and anything the community is doing to discuss, learn about, and contribute to Gutenberg. This is not an exhaustive list; if we are missing your event or article, just let us know. + +## Articles + +A short list of useful articles around defining, extending, and contributing to Gutenberg. + +### Overviews of Gutenberg + +- [Gutenberg, or the Ship of Theseus](https://matiasventura.com/post/gutenberg-or-the-ship-of-theseus/), Matías Ventura Bausero (October 2017) +- [We Called It Gutenberg for a Reason](https://ma.tt/2017/08/we-called-it-gutenberg-for-a-reason/), Matt Mullenweg (August 2017) +- [How Gutenberg is Changing WordPress Development](https://riad.blog/2017/10/06/how-gutenberg-is-changing-wordpress-development/), Riad Benguella (October 2017) +- [How Gutenberg Will Shape the Future of WordPress](https://www.linkedin.com/pulse/gutenberg-morten-rand-hendriksen/), Morten Rand-Henrikson (August 2017) + +### Extending Gutenberg + +- [With Gutenberg, what happens to my Custom Fields?](https://riad.blog/2017/12/11/with-gutenberg-what-happens-to-my-custom-fields/), Riad Benguella (December 2017) +- [One thousand and one ways to extend Gutenberg today](https://riad.blog/2017/10/16/one-thousand-and-one-way-to-extend-gutenberg-today/), Riad Benguella (October 2017) +- [Gutenberg Plugin Boilerplate](https://github.com/ahmadawais/Gutenberg-Boilerplate/), Ahmad Awais (August 2017) + +### Community Contribution + +- [Gutenberg Block Library](https://editorblockswp.com/library), Danny Cooper (August 2018) +- [A zero-configuration developer toolkit for building WordPress Gutenberg block plugins](https://ahmadawais.com/create-guten-block-toolkit/), Ahmad Awais (January 2018) +- [Contributing to Gutenberg Without Code](https://wordimpress.com/a-pot-stirrer-amongst-chefs-contributing-to-gutenberg-without-code/), Kevin Hoffman (August 2017) +- [Testing Flow in Gutenberg: Instructions for how to contribute to usability testing](https://make.wordpress.org/test/2017/11/22/testing-flow-in-gutenberg/), Anna Harrison (November 2017) + +### Article Compilations + +- [Curated Collection of Gutenberg Articles, Plugins, Blocks, Tutorials, etc](http://gutenberghub.com/), By Munir Kamal +- [Articles about Gutenberg](https://github.com/WordPress/gutenberg/issues/1419) (Github Issue thread with links) +- [Gutenberg articles on ManageWP.org](https://managewp.org/search?q=gutenberg) +- [Gutenberg Times](https://gutenbergtimes.com/category/updates/) + +## Talks + +Talks given about Gutenberg, including slides and videos as they are available. + +### Slides +- [The new core WordPress editor](http://kimb.me/talk-bigwp-london-new-core-wordpress-editor/) at BigWP London (18. May 2017) +- [Gutenberg Notes](http://haiku2.com/2017/09/bend-wordpress-meetup-gutenberg-notes/) at Bend WordPress Meetup (5. September 2017) +- [Gutenberg and the Future of Content in WordPress](https://www.slideshare.net/andrewmduthie/gutenberg-and-the-future-of-content-in-wordpress) (20. September 2017) +- [Head first into Gutenberg](https://speakerdeck.com/prtksxna/head-first-into-gutenberg) at the [WordPress Goa Meet-up](https://www.meetup.com/WordPressGoa/events/245275573/) (1. December 2017) +- [Gutenberg : vers une approche plus fine du contenu](https://imathi.eu/2018/02/16/gutenberg-vers-une-approche-plus-fine-du-contenu/) at [WP Paris](https://wpparis.fr/) (8. February 2018) + +### Videos +- [All `Gutenberg` tagged Talks at WordPress.tv](https://wordpress.tv/tag/gutenberg/) +- 2018-Jun - [Beyond Gutenberg](https://wordpress.tv/2018/07/09/matias-ventura-beyond-gutenberg/) by Matías Ventura +- 2018-Jun - [Anatomy of a block: Gutenberg design patterns](https://wordpress.tv/2018/07/08/tammie-lister-anatomy-of-a-block-gutenberg-design-patterns/) by Tammie Lister +- 2017-Dec - [State of the Word 2017](https://wordpress.tv/2017/12/04/matt-mullenweg-state-of-the-word-2017/) by Matt Mullenweg (Gutenberg demo by Matías Ventura at 35:00) +- [Gutenberg is Coming (Don’t Be Afraid)](https://training.ithemes.com/webinar/gutenberg-is-coming-dont-be-afraid/) from iThemes Training + +## Showcases or demonstrations: + +https://wpleeds.co.uk/events/plugins-gutenberg-wordpress-leeds-july-2017/ + +http://kimb.me/talk-bigwp-london-new-core-wordpress-editor + +https://www.facebook.com/events/278785795934302/ + +https://www.meetup.com/WordPress-Melbourne/events/241543639 + +https://wpmeetups.de/termin/29-wp-meetup-stuttgart-gutenberg-editor-rueckblick-wordcamp-europe/ + diff --git a/gb-src/docs/contributors/principles.md b/gb-src/docs/contributors/principles.md new file mode 100644 index 0000000000000..8acedac095826 --- /dev/null +++ b/gb-src/docs/contributors/principles.md @@ -0,0 +1,17 @@ +# Principles + +First, let’s look at the big picture. If the architectural and UX principles described here are activated at scale, how will the Gutenberg project improve and transform both users and creators experiences? + +How Gutenberg can transform the *user experience*: + +* Users can focus on conveying their ideas and information in the way they want without having to understand the underlying technical / semantic distinctions. +* Users can add and edit functionality more easily via the unified mechanism of blocks. +* Everything on a user’s site can be directly manipulated and edited in place without having to rely on traversing complex navigation menus and disparate sections. +* Users only have to learn a single interface — the block — and a single way to add new elements. They will gain the confidence that comes from using a system that feels unified and clear, where everything works in a consistent manner. + +How Gutenberg can transform the *developer and designer experience*: + +* A powerful and expressive toolkit that allows crafting first-class experiences through standardized design and development processes. +* This standardization allows for interoperability — developers can create components that seamlessly connect with components from other developers. +* Relying on consistent UX patterns means developers can be confident their work will be immediately familiar and usable to users and that they don’t have to reinvent interaction patterns. They can focus on their product. +* With one modern, flexible interface, the block, but many ways to bend it, makers have an opportunity to extend WordPress in many new ways. diff --git a/gb-src/docs/contributors/principles/the-block.md b/gb-src/docs/contributors/principles/the-block.md new file mode 100644 index 0000000000000..bdb0c920c77c1 --- /dev/null +++ b/gb-src/docs/contributors/principles/the-block.md @@ -0,0 +1,27 @@ +# Blocks are the Interface + +At the core of Gutenberg lies the concept of the block. From a technical point of view, blocks both raise the level of abstraction from a single document to a collection of meaningful elements, and they replace ambiguity—inherent in HTML—with explicit structure. + +From a user perspective, blocks allow any kind of content, media, or functionality to be directly added to their site in a more consistent and usable way. The “add block” button gives the user access to an entire library of options all in one place, rather than having to hunt through menus or know shortcodes. + +But most importantly, Gutenberg is built on the principle of *direct manipulation*, which means that the primary options for how an element is displayed are controlled *in the context of the block itself*. This is a big shift from the traditional WordPress model, where options that were often buried deep in layers of navigation menus controlled the elements on a page through indirect mechanisms. + +So, for example, a user can add an image, write its caption, change its width and layout, add a link around it, all from within the block interface in the canvas. The same principle should apply to more complex blocks, like a "navigation menu", with the user being able to add, edit, move, and finalize the full presentation of their navigation. + +* Users only need to learn one interface — the block — to add and edit everything on their site. Users shouldn’t have to write shortcodes, custom HTML, or understand hidden mechanisms to embed content. +* Gutenberg makes core features more discoverable, reducing hard-to-find “Mystery meat.” WordPress supports a large number of blocks and 30+ embeds. Let’s increase their visibility. + +## Building Blocks + +What does this mean for designers and developers? The block structure plus the principle of direct manipulation mean thinking differently about how to design and develop WordPress components. Let’s take another look at the architecture of a block: + +![Gutenberg Blueprint](https://cldup.com/LQrPNubkJY.png) + +### The primary interface for a block is the content area of the block. +The placeholder content in the content area of the block can be thought of as a guide or interface for users to follow a set of instructions or “fill in the blanks” (more on placeholders later). Since the content area represents what will actually appear on the site, interaction here hews closest to the principle of direct manipulation and will be most intuitive to the user. This should be thought of as the primary interface for adding and manipulating content and adjusting how it is displayed. + +### The block toolbar is the place for critical options that can’t be incorporated into placeholder UI. +Basic block settings won’t always make sense in the context of the placeholder / content UI. As a secondary option, options that are critical to the functionality of a block can live in the block toolbar. The block toolbar is one step removed from direct manipulation, but is still highly contextual and visible on all screen sizes, so it is a great secondary option. + +### The Settings Sidebar should only be used for advanced, tertiary controls. +The Settings Sidebar is not visible by default on a small / mobile screen, and may also be collapsed even in a desktop view. Therefore, it should not be relied on for anything that is necessary for the basic operation of the block. Pick good defaults, make important actions available in the block toolbar, and think of the sidebar as something that only power users may discover. diff --git a/gb-src/docs/contributors/readme.md b/gb-src/docs/contributors/readme.md new file mode 100644 index 0000000000000..96c3a85424e5d --- /dev/null +++ b/gb-src/docs/contributors/readme.md @@ -0,0 +1,18 @@ +# Contributor Documentation + +Welcome to the Gutenberg Project Contributors Guide. + +The following guidelines are in place to create consistency across the project and the numerous contributors. See the [Contributing Documentation](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md) for technical details around setup, and submitting your contributions. + +## Philosophy + +* [Architectural and UX Principles of Gutenberg](/docs/contributors/principles.md) + +## Sections + +The contributors guide has the following different sections by contribution type: + +* [Design Contributions](/docs/contributors/design.md) +* [Developer Contributions](/docs/contributors/develop.md) +* [Documentation Contributions](/docs/contributors/document.md) + diff --git a/gb-src/docs/contributors/reference.md b/gb-src/docs/contributors/reference.md new file mode 100644 index 0000000000000..95c0b603323e5 --- /dev/null +++ b/gb-src/docs/contributors/reference.md @@ -0,0 +1,17 @@ +# Reference + +- [Glossary](/docs/designers-developers/glossary.md) +- [Coding Guidelines](/docs/contributors/coding-guidelines.md) +- [Testing Overview](/docs/contributors/testing-overview.md) +- [Frequently Asked Questions](/docs/designers-developers/faq.md) + +## Logo +Gutenberg Logo + +Released under GPL license, made by [Cristel Rossignol](https://twitter.com/cristelrossi). + +[Download the SVG logo](https://github.com/WordPress/gutenberg/blob/master/docs/final-g-wapuu-black.svg). + +## Mockups + +Mockup Sketch files are available in [the Design section](/docs/designers-developers/designers/design-resources.md). diff --git a/gb-src/docs/contributors/release-screenshot.png b/gb-src/docs/contributors/release-screenshot.png new file mode 100644 index 0000000000000..63334f489c0b2 Binary files /dev/null and b/gb-src/docs/contributors/release-screenshot.png differ diff --git a/gb-src/docs/contributors/release.md b/gb-src/docs/contributors/release.md new file mode 100644 index 0000000000000..8b3fb3f41b745 --- /dev/null +++ b/gb-src/docs/contributors/release.md @@ -0,0 +1,260 @@ +# Gutenberg Release Process + +This Repository is used to perform several types of releases. This document serves as a checklist for each one of these. It is helpful if you'd like to understand the different workflows. + +To release Gutenberg, you need commit access to the [WordPress.org plugin repository][plugin repository]. 🙂 + +## Plugin Releases + +### Schedule + +We release a new major version approximately every two weeks. The current and next versions are [tracked in GitHub milestones](https://github.com/WordPress/gutenberg/milestones), along with each version's tagging date. + +On the date of the current milestone, we publish a release candidate and make it available for plugin authors and users to test. If any regressions are found with a release candidate, a new release candidate can be published. + +The date in the milestone is the date of **tagging the release candidate**. On this date, all remaining PRs on the milestone are moved automatically to the next release. + +Release candidates should be versioned incrementally, starting with `-rc.1`, then `-rc.2`, and so on. + +Two days after the first release candidate, the stable version is created based on the last release candidate and any necessary regression fixes. + +Once the stable version is released, a post [like this](https://make.wordpress.org/core/2019/06/26/whats-new-in-gutenberg-26th-june/) describing the changes and performing a performance audit should be published. + +If critical bugs are discovered on stable versions of the plugin, patch versions can be released at any time. + +### Release Tool + +The plugin release process is entirely automated. To release the RC version of the plugin, run the following command and follow the instructions: (Note that at the time of writing, the tool doesn't support releasing multiple consecutive RC releases) + +```bash +./bin/commander.js rc +``` + +To release a stable version, run: + +```bash +./bin/commander.js stable +``` + +It is possible to run the "stable" release CLI in a consecutive way to release patch releases following the first stable release. + +### Manual Release Process + +#### Creating the first Release Candidate + +Releasing the first release candidate for this milestone (`x.x`) involves: + +1. writing a release blog post and changelog +2. creating the release branch +3. bumping the version and tagging the release +4. building the plugin +5. publishing the release to GitHub +6. publishing the call for testing + +##### Writing the Release Post and Changelog + +1. Open the [list of closed pull requests](https://github.com/WordPress/gutenberg/pulls?utf8=✓&q=is%3Apr+is%3Aclosed+sort%3Acreated-desc+) and filter by the current milestone. +2. Read through each PR to determine if it needs to be included in the blog post and/or changelog. +3. Choose a few features to highlight in the release post; record an animation of them in use. +4. Save the draft post on [make.wordpress.org/core](https://make.wordpress.org/core/); this post should be published after the actual release. + +##### Creating the Release Branch + +For each milestone (let's assume it's `x.x` here), a release branch is used to release all RCs and minor releases. For the first RC of the milestone, a release branch is created from master. + +``` +git checkout master +git checkout -b release/x.x +git push origin release/x.x +``` + +##### Bumping the Version and Tagging the Release + +1. Checkout the `release/x.x` branch. +2. Create [a commit like this](https://github.com/WordPress/gutenberg/pull/13125/commits/13fa651dadc2472abb9b95f80db9d5f23e63ae9c), bumping the version number in `gutenberg.php`, `package.json`, and `package-lock.json` to `x.x.0-rc.1`. +3. Create a Pull Request from the release branch into `master` using the changelog as a description and ensure the tests pass properly. +4. Tag the RC version. `git tag vx.x.0-rc.1` from the release branch. +5. Push the tag `git push --tags`. +6. Merge the version bump pull request and avoid removing the release branch. + +##### Build the Plugin + +1. Run `git fetch --tags`. +2. Check out the tag for this release, you should run `git checkout vx.x.0-rc.1`. +3. Run `./bin/build-plugin-zip.sh` from the root of project. This packages a zip file with a release build of `gutenberg.zip`. + +##### Publish the Release on GitHub + +1. [Create a new release on GitHub](https://github.com/WordPress/gutenberg/releases/new). +2. If you were releasing the `x.x.0-rc.1` release candidate, label it `x.x.0-rc.1` and use the `vx.x.x-rc.1` as a tag. +3. Upload the a `gutenberg.zip` file into the release. +4. Use the changelog as a description of the release. +5. Publish the release. + +Here's an example [release candidate page](https://github.com/WordPress/gutenberg/releases/tag/v4.6.0-rc.1); yours should look like that when you're finished. + +#### Creating Release Candidate Patches (done via `git cherry-pick`) + +If a bug is found in a release candidate and a fix is committed to `master`, we should include that fix in a new release candidate. To do this you'll need to use `git cherry-pick` to add these changes to the milestone's release branch. This way only fixes are added to the release candidate and not all the new code that has landed on `master` since tagging: + +1. Checkout the corresponding release branch with: `git checkout release/x.x`. +2. Cherry-pick fix commits (in chronological order) with `git cherry-pick [SHA]`. +3. Create [a commit like this](https://github.com/WordPress/gutenberg/pull/13125/commits/13fa651dadc2472abb9b95f80db9d5f23e63ae9c), bumping the version number in `gutenberg.php`, `package.json`, and `package-lock.json` to `x.x.0-rc.2`. +4. Create a Pull Request from the release branch into `master` using the changelog as a description and ensure the tests pass properly. +5. Tag the RC version. `git tag vx.x.0-rc.2` from the release branch. +6. Push the tag `git push --tags`. +7. Merge the version bump pull request and avoid removing the release branch. +8. Follow the steps in [build the plugin](#build-the-plugin) and [publish the release on GitHub](#publish-the-release-on-github). + +You can copy the existing changelog from the previous release candidate. Let other contributors know that a new release candidate has been released in the [`#core-editor` channel](https://wordpress.slack.com/messages/C02QB2JS7) and the call for testing post. + +### Official Gutenberg Releases™ + +The process of releasing Gutenberg is similar to creating a release candidate, except we don't use the `-rc.X` in the `git` tag and we publish a new branch in the subversion repository. This updates the version available in the WordPress plugin repository and will cause WordPress sites around the world to prompt users to update to this new version. + +#### Creating a Release + +Creating a release involves: + +1. verifying the release blog post and changelog +2. bumping the version +3. building the plugin +4. publishing the new release to GitHub +5. committing to the [plugin repository] +6. publishing the release blog post + +##### Verifying the Release Post and Changelog + +1. Check the draft post on [make.wordpress.org/core](https://make.wordpress.org/core/); make sure the changelog reflects what's shipping in the release. + +##### Bumping the Version + +1. Checkout the release branch `git checkout release/x.x`. + +**Note:** This branch should never be removed or rebased. When we want to merge something from it to master and conflicts exist/may exist we use a temporary branch `bump/x.x`. + +2. Create [a commit like this](https://github.com/WordPress/gutenberg/commit/00d01049685f11f9bb721ad3437cb928814ab2a2#diff-b9cfc7f2cdf78a7f4b91a753d10865a2), removing the `-rc.X` from the version number in `gutenberg.php`, `package.json`, and `package-lock.json`. +3. Create a new branch called `bump/x.x` from `release/x.x` and switch to it: `git checkout -b bump/x.x`. +4. Create a pull request from `bump/x.x` to `master`. Verify the continuous integrations tests pass, before continuing to the next step even if conflicts exist. +5. Rebase `bump/x.x` against `origin/master` using `git fetch origin && git rebase origin/master`. +6. Force push the branch `bump/x.x` using `git push --force-with-lease`. +7. Switch to the `release/x.x` branch. Tag the version from the release branch `git tag vx.x.0`. +8. Push the tag `git push --tags`. +9. Merge the version bump pull request. + + +##### Build the Plugin + +1. Run `git fetch --tags`. +2. Check out the tag for this release, you should run `git checkout vx.x.0`. +3. Run `./bin/build-plugin-zip.sh` from the root of project. This packages a zip file with a release build of `gutenberg.zip`. + +##### Publish the Release on GitHub + +1. [Create a new release on GitHub](https://github.com/WordPress/gutenberg/releases/new). +2. If you were releasing the `x.x.0` release candidate, label it `x.x.0` and use the `vx.x.x` as a tag. +3. Upload the a `gutenberg.zip` file into the release. +4. Use the changelog as a description of the release. +5. Publish the release. + +##### Commit to the Plugin Repository + +You'll need to use Subversion to publish the plugin to WordPress.org. + +1. Do an SVN checkout of `https://wordpress.org/plugins/gutenberg/trunk`: + * If this is your first checkout, run: `svn checkout https://plugins.svn.wordpress.org/gutenberg/trunk` + * If you already have a copy, run: `svn up` +2. Delete the contents except for the `readme.txt` and `changelog.txt` files (these files don’t exist in the `git` repo, only in Subversion). +3. Extract the contents of the zip file. +4. Edit `readme.txt`, replacing the changelog for the previous version with the current release's changelog. +5. Add the changelog for the current release to `changelog.txt`. +6. Add new files/remove deleted files from the repository: +```bash +# Add new files: +svn st | grep '^\?' | awk '{print $2}' | xargs svn add +# Delete old files: +svn st | grep '^!' | awk '{print $2}' | xargs svn rm +``` +7. Commit the new version: +```bash +# Replace X.X.X with your version: +svn ci -m "Committing Gutenberg version X.X.X" +``` +8. Tag the new version: +```bash +svn cp https://plugins.svn.wordpress.org/gutenberg/trunk https://plugins.svn.wordpress.org/gutenberg/tags/X.X.X -m "Tagging Gutenberg version X.X.X" +``` +9. Edit `readme.txt` to point to the new tag. The **Stable version** header in `readme.txt` should be updated to match the new release version number. After updating and committing that, the new version should be released: +```bash +svn ci -m "Releasing Gutenberg version X.X.X" +``` + +This will cause the new version to be available to users of WordPress all over the globe! 💃 + +You should check that folks are able to install the new version from their Dashboard. + +### Publish the Release Blog Post + +1. Publish the [make/core](https://make.wordpress.org/core/) release blog post drafted earlier. +2. Pat yourself on the back! 👍 + +If you don't have access to [make.wordpress.org/core](https://make.wordpress.org/core/), ping [someone on the Gutenberg Core team](https://github.com/orgs/WordPress/teams/gutenberg-core) in the [WordPress #core-editor Slack channel](https://wordpress.slack.com/messages/C02QB2JS7) to publish the post. + +## Packages Releases and WordPress Core Updates + +The Gutenberg repository mirrors the [WordPress SVN repository](https://make.wordpress.org/core/handbook/about/release-cycle/) in terms of branching for each SVN branch, a corresponding Gutenberg `wp/*` branch is created: + + - The `wp/trunk` branch contains all the packages that are published and used in the `trunk` branch of WordPress. + - A Gutenberg branch targeting a specific WordPress major release (including its further minor increments) is created (example `wp/5.2`) based on the `wp/trunk` Gutenberg branch when the WordPress `trunk` branch is marked as "feature-freezed". (This usually happens when the first `beta` of the next WordPress major version is released). + +### Synchronizing WordPress Trunk + +For each Gutenberg plugin release, WordPress trunk should be synchronized with this release. This involves the following steps: + +**Note:** The WordPress `trunk` branch can be closed or in "feature-freeze" mode. Usually, this happens between the first `beta` and the first `RC` of the WordPress release cycle. During this period, the Gutenberg plugin releases should not be synchronized with WordPress Core. + +1. Ensure the WordPress `trunk` branch is open for enhancements. +2. Check out the last published Gutenberg release branch `git checkout release/x.x` +3. Create a Pull Request from this branch targeting `wp/trunk`. +4. Merge the Pull Request using the "Rebase and Merge" button to keep the history of the commits. + +Now, the branch is ready to be used to publish the npm packages. + +1. Check out the `wp/trunk` branch. +2. Run the [package release process] but when asked for the version numbers to choose for each package, (assuming the package versions are written using this format `major.minor.patch`) make sure to bump at least the `minor` version number. For example, if the CHANGELOG of the package to be released indicates that the next unreleased version is `5.6.1`, choose `5.7.0` as a version. +3. Update the `CHANGELOG.md` files of the published packages with the new released versions and commit to the `wp/trunk` branch. +4. Cherry-pick the "Publish" (created by Lerna) and the CHANGELOG update commits into the `master` branch of Gutenberg. + +Now, the npm packages should be ready and a patch can be created and committed into WordPress `trunk`. + + +### Minor WordPress Releases + +The following workflow is needed when bug fixes or security releases need to be backported into WordPress Core. This can happen in a few use-cases: + + - During the `beta` and the `RC` period of the WordPress release cycle. + - For WordPress minor releases and WordPress security releases (example `5.1.1`). + +1. Cherry-pick +2. Check out the last published Gutenberg release branch `git checkout release/x.x` +3. Create a Pull Request from this branch targeting the WordPress related major branch (Example `wp/5.2`). +4. Merge the Pull Request using the "Rebase and Merge" button to keep the history of the commits. + +Now, the branch is ready to be used to publish the npm packages. + +1. Check out the WordPress branch used before (Example `wp/5.2`). +2. Run the [package release process] but when asked for the version numbers to choose for each package, (assuming the package versions are written using this format `major.minor.patch`) make sure to bump only the `patch` version number. For example, if the last published package version for this WordPress branch was `5.6.0`, choose `5.6.1` as a version. + +**Note:** For WordPress `5.0` and WordPress `5.1`, a different release process was used. This means that when choosing npm package versions targeting these two releases, you won't be able to use the next `patch` version number as it may have been already used. You should use the "metadata" modifier for these. For example, if the last published package version for this WordPress branch was `5.6.1`, choose `5.6.1+patch.1` as a version. + +3. Update the `CHANGELOG.md` files of the published packages with the new released versions and commit to the corresponding branch (Example `wp/5.2`). +4. Cherry-pick the CHANGELOG update commits into the `master` branch of Gutenberg. + +Now, the npm packages should be ready and a patch can be created and committed into the corresponding WordPress SVN branch. + +--------- + +Ta-da! 🎉 + +[plugin repository]: https://plugins.trac.wordpress.org/browser/gutenberg/ +[package release process]: https://github.com/WordPress/gutenberg/blob/master/packages/README.md#releasing-packages diff --git a/gb-src/docs/contributors/repository-management.md b/gb-src/docs/contributors/repository-management.md new file mode 100644 index 0000000000000..cf7dbcee1b25e --- /dev/null +++ b/gb-src/docs/contributors/repository-management.md @@ -0,0 +1,186 @@ +# Repository Management + +This is a living document explaining how we collaboratively manage the Gutenberg repository. If you’d like to suggest a change, please open an issue for discussion or submit a pull request to the document. + +This document covers: + +- [Issues](#issues) + - [Labels](#labels) + - [Milestones](#milestones) + - [Triaging Issues](#triaging-issues) +- [Pull Requests](#pull-requests) + - [Code Review](#code-review) + - [Design Review](#design-review) + - [Merging Pull Requests](#merging-pull-requests) + - [Closing Pull Requests](#closing-pull-requests) +- [Projects](#projects) + +## Issues + +A healthy issue list is one where issues are relevant and actionable. *Relevant* in the sense that they relate to the project’s current priorities. *Actionable* in the sense that it’s clear what action(s) need to be taken to resolve the issue. + +Any issues that are irrelevant or not actionable should be closed, because they get in the way of making progress on the project. Imagine the issue list as a desk: the more clutter you have on it, the more difficult it is to use the space to get work done. + +### Labels + +All issues should have [one or more labels](https://github.com/WordPress/gutenberg/labels). + +Workflow labels start with “Needs” and may be applied as needed. Ideally, each workflow label will have a group that follows it, such as the Accessibility Team for `Needs Accessibility Feedback`, the Testing Team for `Needs Testing`, etc. + +[Priority High](https://github.com/WordPress/gutenberg/labels/Priority%20High) and [Priority OMGWTFBBQ](https://github.com/WordPress/gutenberg/labels/Priority%20OMGWTFBBQ) issues should have an assignee and/or be in an active milestone. + +Help requests or 'how to' questions should be posted in a relevant support forum as a first step. If something might be a bug but it's not clear, the Support Team or a forum volunteer can help troubleshoot the case to help get all the right information needed for an effective bug report. + +Here are some labels you might commonly see: + +- [Good First Issue](https://github.com/WordPress/gutenberg/labels/Good%20First%20Issue) - Issues identified as good for new contributors to work on. Comment to note that you intend to work on the issue and reference the issue number in the pull request you submit. +- [Good First Review](https://github.com/WordPress/gutenberg/labels/Good%20First%20Review) - Pull requests identified as good for new contributors who are interested in doing code reviews. +- [Needs Accessibility Feedback](https://github.com/WordPress/gutenberg/labels/Accessibility) - Changes that impact accessibility and need corresponding review (e.g. markup changes). +- [Needs Design Feedback](https://github.com/WordPress/gutenberg/labels/Needs%20Design%20Feedback) - Changes that modify the design or user experience in some way and need sign-off. +- [[Type] Bug](https://github.com/WordPress/gutenberg/labels/%5BType%5D%20Bug) - An existing feature is broken in some way. +- [[Type] Enhancement](https://github.com/WordPress/gutenberg/labels/%5BType%5D%20Enhancement) - Gutenberg would be better with this improvement added. +- [[Type] Plugin / Extension Conflict](https://github.com/WordPress/gutenberg/labels/%5BType%5D%20Plugin%20%2F%20Extension%20Conflict) - Documentation of a conflict between Gutenberg and a plugin or extension. The plugin author should be informed and provided documentation on how to address. +- [[Status] Needs More Info](https://github.com/WordPress/gutenberg/labels/%5BStatus%5D%20Needs%20More%20Info) - The issue needs more information in order to be actionable and relevant. Typically this requires follow-up from the original reporter. + +[Check out the label directory](https://github.com/WordPress/gutenberg/labels) for a listing of all labels. + +### Milestones + +We put issues into [milestones](https://github.com/wordpress/gutenberg/milestones) to better categorize them. Issues are added to milestones starting with `WordPress` and pull requests are added to milestones ending in `(Gutenberg)`. + +Here are some milestones you might see: + +- [WordPress X.Y](https://github.com/WordPress/gutenberg/milestone/70): Tasks that should be done for future WordPress releases. +- [X.Y (Gutenberg)](https://github.com/WordPress/gutenberg/milestone/85): PRs targeted for the Gutenberg Plugin X.Y release. +- [Future](https://github.com/WordPress/gutenberg/milestone/35): this is something that is confirmed by everyone as a good thing but doesn’t fall into other criteria. + +### Triaging Issues + +To keep the issue list healthy, it needs to be triaged regularly. *Triage* is the practice of reviewing existing issues to make sure they’re relevant, actionable, and have all the information they need. + +Anyone can help triage, although you’ll need contributor permission on the Gutenberg repository to modify an issue’s labels or edit its title. + +To start simply choose from one of these filtered lists of issues: + +- [All Gutenberg issues without an assigned label](https://github.com/wordpress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc+no%3Alabel). Triaging by simply adding labels helps people focused on certain aspects of Gutenberg find relevant issues easier and start working on them. +- [The least recently updated Gutenberg issues](https://github.com/WordPress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc). Triaging issues that are getting old and possibly out of date keeps important work from being overlooked. +- [All Gutenberg issues with no comments](https://github.com/WordPress/gutenberg/issues?q=is%3Aopen+is%3Aissue+sort%3Acomments-asc) Triaging this list helps make sure all issues are acknowledged, and can help identify issues that may need more information or discussion before they are actionable. +- [The least commented on issues](https://github.com/WordPress/gutenberg/issues?q=is%3Aopen+is%3Aissue+sort%3Acomments-asc) Triaging this list helps the community figure out things like traction for certain proposals. + +You can also create your own custom set of filters on GitHub. If you have a filter you think might be useful for the community, feel free to submit a PR to add it to this list. + +When triaging, either one of the lists above or issues in general, here are some steps you can perform: + +- First search for duplicates. If the issue is duplicate, close it by commenting with “Duplicate of #” and add any relevant new details to the existing issue. +- If the issue is missing labels, add some to better categorize it (requires proper permissions). +- If the title doesn’t communicate the issue, edit it for clarity (requires proper permissions). +- If it’s a bug report, test to confirm the report or add the `Needs Testing` label. If there is not enough information to confirm the report, add the `[Status] Needs More Info` label and ask for the details needed. +- Remove the `[Status] Needs More Info` if the author of the issue has responded with enough details. +- Close the issue with a note if it has a `[Status] Needs More Info` label but the author didn't respond in 2+ weeks. +- If there was conversation on the issue but no actionable steps identified, follow up with the participants to see what’s actionable. +- If you feel comfortable triaging the issue further, then you can also: + - Check that the bug report is valid by debugging it to see if you can track down the technical specifics. + - Check if the issue is missing some detail and see if you can fill in those details. For instance, if a bug report is missing visual detail, it’s helpful to reproduce the issue locally and upload a screenshot or GIF. + +For triaging there are some labels which are very useful: +- `Needs Technical Feedback` - you can apply them when you see new features or API changes proposed +- `Needs More Info` - when it’s not clear what the issue is or it would help to provide additional details +- `Needs Testing` - it’s useful for old bugs where it seems like they are no longer relevant + +## Pull Requests + +Gutenberg follows a feature branch pull request workflow for all code and documentation changes. At a high-level, the process looks like this: + +1. Check out a new feature branch locally. +2. Make your changes, testing thoroughly. +3. Commit your changes when you’re happy with them, and push the branch. +4. Open your pull request. + +Along with this process, there are a few important points to mention: + +- Non-trivial pull requests should be preceded by a related issue that defines the problem to solve and allows for discussion of the most appropriate solution before actually writing code. +- To make it far easier to merge your code, each pull request should only contain one conceptual change. Keeping contributions atomic keeps the pull request discussion focused on one topic and makes it possible to approve changes on a case-by-case basis. +- Separate pull requests can address different items or todos from their linked issue, there’s no need for a single pull request to cover a single issue if the issue is non-trivial. + +### Code Review + +Every pull request goes through a manual code review, in addition to automated tests. The objectives for the code review are best thought of as: + +- Correct — Does the change do what it’s supposed to? +- Secure — Would a nefarious party find some way to exploit this change? +- Readable — Will your future self be able to understand this change months down the road? +- Elegant — Does the change fit aesthetically within the overall style and architecture? +- Altruistic — How does this change contribute to the greater whole? + +*As a reviewer*, your feedback should be focused on the idea, not the person. Seek to understand, be respectful, and focus on constructive dialog. + +*As a contributor*, your responsibility is to learn from suggestions and iterate your pull request should it be needed based on feedback. Seek to collaborate and produce the best possible contribution to the greater whole. + +Code reviews are encouraged by everyone who is willing to attempt one. If you review a pull request and are confident in the changes, approve it. If you don't feel totally confident it is ready for merging, add your review with a comment that says it should have another set of eyes on it before final approval. This can help filter out obvious bugs and simplify reviews for core members. Following the later reviews will also help improve your reviewing confidence in the future. + +If you are not yet comfortable leaving a full review, try commenting on a PR. Questions about functionality or the reasoning behind a change are helpful too. You could also comment on changes to parts of the code you understand, without leaving a full review. + +### Design Review + +If your pull request impacts the design, you should ask for a design review. To request a design review add the [Needs Design Feedback](https://github.com/WordPress/gutenberg/labels/Needs%20Design%20Feedback) label to your PR. As a guide, changes that should be reviewed: + +- A change based on a previous design, to confirm the design is still valid with the change. +- Anything that changes something visually. +- If you just want design feedback on an idea or exploration. + +### Merging Pull Requests + +A pull request can generally be merged once it is: + +- Deemed a worthwhile change to the codebase. +- In compliance with all relevant code review criteria. +- Covered by sufficient tests, as necessary. +- Vetted against all potential edge cases. +- Changelog entries were properly added. +- Reviewed by someone other than the original author. +- [Rebased](/docs/contributors/git-workflow.md#keeping-your-branch-up-to-date) onto the latest version of the master branch. + +The final pull request merge decision is made by the **@wordpress/gutenberg-core** team. + +All members of the WordPress organization on GitHub have the ability to review and merge pull requests. If you have reviewed a PR and are confident in the code, approve the pull request and comment pinging **@wordpress/gutenberg-core** or a specific core member who has been involved in the PR. Once they confirm there are no objections, you are free to merge the PR into master. + +Most pull requests will be automatically assigned a release milestone, but please make sure your merged pull request was assigned one. Doing so creates the historical legacy of what code landed when, and makes it possible for all project contributors (even non-technical ones) to access this information. + + +### Closing Pull Requests + +Sometimes, a pull request may not be mergeable, no matter how much additional effort is applied to it (e.g. out of scope). In these cases, it’s best to communicate with the contributor graciously while describing why the pull request was closed, this encourages productive future involvement. + +Make sure to: + +1. Thank the contributor for their time and effort. +2. Fully explain the reasoning behind the decision to close the pull request. +3. Link to as much supporting documentation as possible. + +If you’d like a template to follow: + +> Thanks ____ for the time you’ve spent on this pull request. +> +> I’m closing this pull request because ____. To clarify further, ____. +> +> For more details, please see ____ and ____. + +## Teams + +Two GitHub teams are used in the project. + +* [Gutenberg Core](https://github.com/orgs/WordPress/teams/gutenberg-core): A team composed of people that are actively involved in the project: attending meetings regularly, participating in triage sessions, performing reviews regularly, working on features and bug fixes and performing plugin and npm releases. + +* [Gutenberg](https://github.com/orgs/WordPress/teams/gutenberg): A team composed of contributors with at least 2–3 meaningful contributions to the project. + +If you meet this criteria of several meaningful contributions having been accepted into the repository and would like to be added to the Gutenberg team, feel free to ask in the [#core-editor Slack channel](https://make.wordpress.org/chat/). + +## Projects + +We use [GitHub projects](https://github.com/WordPress/gutenberg/projects) to keep track of details that aren't immediately actionable, but that we want to keep around for future reference. + +Some key projects include: + +* [Phase 2](https://github.com/WordPress/gutenberg/projects/13) - Development tasks needed for Phase 2 of Gutenberg. +* [Phase 2 design](https://github.com/WordPress/gutenberg/projects/21) - Tasks for design in Phase 2. Note: specific projects may have their own boards. +* [Ideas](https://github.com/WordPress/gutenberg/projects/8) - Project containing tickets that, while closed for the time being, can be revisited in the future. diff --git a/gb-src/docs/contributors/scripts.md b/gb-src/docs/contributors/scripts.md new file mode 100644 index 0000000000000..28a768e41599c --- /dev/null +++ b/gb-src/docs/contributors/scripts.md @@ -0,0 +1,79 @@ +# Scripts + +The editor provides several vendor and internal scripts to plugin developers. Script names, handles, and descriptions are documented in the table below. + +## WP Scripts + +The editor includes a number of packages to enable various pieces of functionality. Plugin developers can utilize them to create blocks, editor plugins, or generic plugins. + +| Script Name | Handle | Description | +|-------------|--------|-------------| +| [Blob](/packages/blob/README.md) | wp-blob | Blob utilities | +| [Block Library](/packages/block-library/README.md) | wp-block-library | Block library for the editor | +| [Blocks](/packages/blocks/README.md) | wp-blocks | Block creations | +| [Block Serialization Default Parser](/packages/block-serialization-default-parser/README.md) | wp-block-serialization-default-parser | Default block serialization parser implementations for WordPress documents | +| [Block Serialization Spec Parser](/packages/block-serialization-spec-parser/README.md) | wp-block-serialization-spec-parser | Grammar file (grammar.pegjs) for WordPress posts | +| [Components](/packages/components/README.md) | wp-components | Generic components to be used for creating common UI elements | +| [Compose](/packages/compose/README.md) | wp-compose | Collection of handy Higher Order Components (HOCs) | +| [Core Data](/packages/core-data/README.md) | wp-core-data | Simplify access to and manipulation of core WordPress entities | +| [Data](/packages/data/README.md) | wp-data | Data module serves as a hub to manage application state for both plugins and WordPress itself | +| [Date](/packages/date/README.md) | wp-date | Date module for WordPress | +| [Deprecated](/packages/deprecated/README.md) | wp-deprecated | Utility to log a message to notify developers about a deprecated feature | +| [Dom](/packages/dom/README.md) | wp-dom | DOM utilities module for WordPress | +| [Dom Ready](/packages/dom-ready/README.md) | wp-dom-ready | Execute callback after the DOM is loaded | +| [Editor](/packages/editor/README.md) | wp-editor | Building blocks for WordPress editors | +| [Edit Post](/packages/edit-post/README.md) | wp-edit-post | Edit Post Module for WordPress | +| [Element](/packages/element/README.md) | wp-element |Element is, quite simply, an abstraction layer atop [React](https://reactjs.org/) | +| [Escape Html](/packages/escape-html/README.md) | wp-escape-html | Escape HTML utils | +| [Hooks](/packages/hooks/README.md) | wp-hooks | A lightweight and efficient EventManager for JavaScript | +| [Html Entities](/packages/html-entities/README.md) | wp-html-entities | HTML entity utilities for WordPress | +| [I18N](/packages/i18n/README.md) | wp-i18n | Internationalization utilities for client-side localization | +| [Is Shallow Equal](/packages/is-shallow-equal/README.md) | wp-is-shallow-equal | A function for performing a shallow comparison between two objects or arrays | +| [Keycodes](/packages/keycodes/README.md) | wp-keycodes | Keycodes utilities for WordPress, used to check the key pressed in events like `onKeyDown` | +| [List Reusable Blocks](/packages/list-reusable-blocks/README.md) | wp-list-reusable-blocks | Package used to add import/export links to the listing page of the reusable blocks | +| [NUX](/packages/nux/README.md) | wp-nux | Components, and wp.data methods useful for onboarding a new user to the WordPress admin interface | +| [Plugins](/packages/plugins/README.md) | wp-plugins | Plugins module for WordPress | +| [Redux Routine](/packages/redux-routine/README.md) | wp-redux-routine | Redux middleware for generator coroutines | +| [Rich Text](/packages/rich-text/README.md) | wp-rich-text | Helper functions to convert HTML or a DOM tree into a rich text value and back | +| [Shortcode](/packages/shortcode/README.md) | wp-shortcode | Shortcode module for WordPress | +| [Token List](/packages/token-list/README.md) | wp-token-list | Constructable, plain JavaScript [DOMTokenList](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList) implementation, supporting non-browser runtimes | +| [URL](/packages/url/README.md) | wp-url | A collection of utilities to manipulate URLs | +| [Viewport](/packages/viewport/README.md) | wp-viewport | Module for responding to changes in the browser viewport size | +| [Wordcount](/packages/wordcount/README.md) | wp-wordcount | WordPress word count utility | + +## Vendor Scripts + +The editor also uses some popular third-party packages and scripts. Plugin developers can use these scripts as well without bundling them in their code (and increasing file sizes). + +| Script Name | Handle | Description | +|-------------|--------|-------------| +| [React](https://reactjs.org) | react | React is a JavaScript library for building user interfaces | +| [React Dom](https://reactjs.org/docs/react-dom.html) | react-dom | Serves as the entry point to the DOM and server renderers for React, intended to be paired with React | +| [Moment](https://momentjs.com/) | moment| Parse, validate, manipulate, and display dates and times in JavaScript | +| [Lodash](https://lodash.com) | lodash| Lodash is a JavaScript library which provides utility functions for common programming tasks | + +## Polyfill Scripts + +The editor also provides polyfills for certain features that may not be available in all modern browsers. +It is recommended to use the main `wp-polyfill` script handle which takes care of loading all the below mentioned polyfills. + +| Script Name | Handle | Description | +|-------------|--------|-------------| +| [Babel Polyfill](https://babeljs.io/docs/en/babel-polyfill) | wp-polyfill | Emulate a full ES2015+ environment. Main script to load all the below mentioned additional polyfills | +| [Fetch Polyfill](https://www.npmjs.com/package/whatwg-fetch) | wp-polyfill-fetch | Polyfill that implements a subset of the standard Fetch specification | +| [Promise Polyfill](https://www.npmjs.com/package/promise-polyfill) | wp-polyfill-promise| Lightweight ES6 Promise polyfill for the browser and node | +| [Formdata Polyfill](https://www.npmjs.com/package/formdata-polyfill) | wp-polyfill-formdata| Polyfill conditionally replaces the native implementation | +| [Node Contains Polyfill](https://polyfill.io) | wp-polyfill-node-contains |Polyfill for Node.contains | +| [Element Closest Polyfill](https://www.npmjs.com/package/element-closest) | wp-polyfill-element-closest| Return the closest element matching a selector up the DOM tree | + +## Bundling and code sharing + +When using a JavaScript bundler like [webpack](https://webpack.js.org/), the scripts mentioned here +can be excluded from the bundle and provided by WordPress in the form of script dependencies [(see +`wp_enqueue_script`)][https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress]. + +The +[`@wordpress/dependency-extraction-webpack-plugin`](https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin) +provides a webpack plugin to help extract WordPress dependencies from bundles. `@wordpress/scripts` +[`build`](https://github.com/WordPress/gutenberg/tree/master/packages/scripts#build) script includes +the plugin by default. diff --git a/gb-src/docs/contributors/testing-overview.md b/gb-src/docs/contributors/testing-overview.md new file mode 100644 index 0000000000000..ef2fab465927d --- /dev/null +++ b/gb-src/docs/contributors/testing-overview.md @@ -0,0 +1,388 @@ +# Testing Overview + +Gutenberg contains both PHP and JavaScript code, and encourages testing and code style linting for both. + +## Why test? + +Aside from the joy testing will bring to your life, tests are important not only because they help to ensure that our application behaves as it should, but also because they provide concise examples of how to use a piece of code. + +Tests are also part of our code base, which means we apply to them the same standards we apply to all our application code. + +As with all code, tests have to be maintained. Writing tests for the sake of having a test isn't the goal – rather we should try to strike the right balance between covering expected and unexpected behaviours, speedy execution and code maintenance. + +When writing tests consider the following: + +* What behaviour(s) are we testing? +* What errors are likely to occur when we run this code? +* Does the test test what we think it is testing? Or are we introducing false positives/negatives? +* Is it readable? Will other contributors be able to understand how our code behaves by looking at its corresponding test? + +## JavaScript Testing + +Tests for JavaScript use [Jest](https://jestjs.io/) as the test runner and its API for [globals](https://jestjs.io/docs/en/api.html) (`describe`, `test`, `beforeEach` and so on) [assertions](https://jestjs.io/docs/en/expect.html), [mocks](https://jestjs.io/docs/en/mock-functions.html), [spies](https://jestjs.io/docs/en/jest-object.html#jestspyonobject-methodname) and [mock functions](https://jestjs.io/docs/en/mock-function-api.html). If needed, you can also use [Enzyme](https://github.com/airbnb/enzyme) for React component testing. + +Assuming you've followed the [instructions](/docs/contributors/getting-started.md) to install Node and project dependencies, tests can be run from the command-line with NPM: + +``` +npm test +``` + +Code style in JavaScript is enforced using [ESLint](http://eslint.org/). The above `npm test` will execute both unit tests and code linting. Code linting can be verified independently by running `npm run lint`. ESLint can also fix not all, but many issues automatically by running `npm run lint:fix`. To reduce the likelihood of unexpected build failures caused by code styling issues, you're encouraged to [install an ESLint integration for your editor](https://eslint.org/docs/user-guide/integrations) and/or create a [git pre-commit hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) containing the `npm run lint:fix` command. + +To run unit tests only, without the linter, use `npm run test-unit` instead. + +### Folder structure + +Keep your tests in a `test` folder in your working directory. The test file should have the same name as the test subject file. + +``` ++-- test +| +-- bar.js ++-- bar.js +``` + +Only test files (with at least one test case) should live directly under `/test`. If you need to add external mocks or fixtures, place them in a sub folder, for example: + +* `test/mocks/[file-name].js` +* `test/fixtures/[file-name].js` + +### Importing tests + +Given the previous folder structure, try to use relative paths when importing of the __code you're testing__, as opposed to using project paths. + +**Good** + +`import { bar } from '../bar';` + +**Not so good** + +`import { bar } from 'components/foo/bar';` + +It will make your life easier should you decide to relocate your code to another position in the application directory. + +### Describing tests + +Use a `describe` block to group test cases. Each test case should ideally describe one behaviour only. + +In test cases, try to describe in plain words the expected behaviour. For UI components, this might entail describing expected behaviour from a user perspective rather than explaining code internals. + +**Good** + +```javascript +describe( 'CheckboxWithLabel', () => { + test( 'checking checkbox should disable the form submit button', () => { + ... + } ); +} ); +``` + +**Not so good** + +```javascript +describe( 'CheckboxWithLabel', () => { + test( 'checking checkbox should set this.state.disableButton to `true`', () => { + ... + } ); +} ); +``` + +### Setup and Teardown methods + +The Jest API includes some nifty [setup and teardown methods](https://jestjs.io/docs/en/setup-teardown.html) that allow you to perform tasks *before* and *after* each or all of your tests, or tests within a specific `describe` block. + +These methods can handle asynchronous code to allow setup that you normally cannot do inline. As with [individual test cases](https://jestjs.io/docs/en/asynchronous.html#promises), you can return a Promise and Jest will wait for it to resolve: + +```javascript +// one-time setup for *all* tests +beforeAll( () => someAsyncAction().then( resp => { + window.someGlobal = resp; +} ) ); + +// one-time teardown for *all* tests +afterAll( () => { + window.someGlobal = null; +} ); +``` + +`afterEach` and `afterAll` provide a perfect (and preferred) way to 'clean up' after our tests, for example, by resetting state data. + +Avoid placing clean up code after assertions since, if any of those tests fail, the clean up won't take place and may cause failures in unrelated tests. + +### Mocking dependencies + +#### Dependency injection + +Passing dependencies to a function as arguments can often make your code simpler to test. Where possible, avoid referencing dependencies in a higher scope. + +**Not so good** + +```javascript +import VALID_VALUES_LIST from './constants' + +function isValueValid( value ) { + return VALID_VALUES_LIST.includes( value ); +} +``` + +Here we'd have to import and use a value from `VALID_VALUES_LIST` in order to pass: + +`expect( isValueValid( VALID_VALUES_LIST[ 0 ] ) ).toBe( true );` + +The above assertion is testing two behaviours: 1) that the function can detect an item in a list, and 2) that it can detect an item in `VALID_VALUES_LIST`. + +But what if we don't care what's stored in `VALID_VALUES_LIST`, or if the list is fetched via an HTTP request, and we only want to test whether `isValueValid` can detect an item in a list? + +**Good** + +```javascript +function isValueValid( value, validValuesList = [] ) { + return validValuesList.includes( value ); +} +``` + +Because we're passing the list as an argument, we can pass mock `validValuesList` values in our tests and, as a bonus, test a few more scenarios: + +`expect( isValueValid( 'hulk', [ 'batman', 'superman' ] ) ).toBe( false );` + +`expect( isValueValid( 'hulk', null ) ).toBe( false );` + +`expect( isValueValid( 'hulk', [] ) ).toBe( false );` + +`expect( isValueValid( 'hulk', [ 'iron man', 'hulk' ] ) ).toBe( true );` + +#### Imported dependencies + +Often our code will use methods and properties from imported external and internal libraries in multiple places, which makes passing around arguments messy and impracticable. For these cases `jest.mock` offers a neat way to stub these dependencies. + +For instance, lets assume we have `config` module to control a great deal of functionality via feature flags. + +```javascript +// bilbo.js +import config from 'config'; +export const isBilboVisible = () => config.isEnabled( 'the-ring' ) ? false : true; +``` + +To test the behaviour under each condition, we stub the config object and use a jest mocking function to control the return value of `isEnabled`. + +```javascript +// test/bilbo.js +import { isEnabled } from 'config'; +import { isBilboVisible } from '../bilbo'; + +jest.mock( 'config', () => ( { + // bilbo is visible by default + isEnabled: jest.fn( () => false ), +} ) ); + +describe( 'The bilbo module', () => { + test( 'bilbo should be visible by default', () => { + expect( isBilboVisible() ).toBe( true ); + } ); + + test( 'bilbo should be invisible when the `the-ring` config feature flag is enabled', () => { + isEnabled.mockImplementationOnce( name => name === 'the-ring' ); + expect( isBilboVisible() ).toBe( false ); + } ); +} ); +``` + +### Testing globals + +We can use [Jest spies](https://jestjs.io/docs/en/jest-object.html#jestspyonobject-methodname) to test code that calls global methods. + +```javascript +import { myModuleFunctionThatOpensANewWindow } from '../my-module'; + +describe( 'my module', () => { + beforeAll( () => { + jest.spyOn( global, 'open' ) + .mockImplementation( () => true ); + } ); + + test( 'something', () => { + myModuleFunctionThatOpensANewWindow(); + expect( global.open ).toHaveBeenCalled(); + } ); +} ); +``` + +### Snapshot testing + +This is an overview of [snapshot testing] and how to best leverage snapshot tests. + +#### TL;DR Broken snapshots + +When a snapshot test fails, it just means that a component's rendering has changed. If that was unintended, then the snapshot test just prevented a bug 😊 + +However, if the change was intentional, follow these steps to update the snapshot. Run the following to update the snapshots: + ```sh + # --testPathPattern is optional but will be much faster by only running matching tests + npm run test-unit -- --updateSnapshot --testPathPattern path/to/tests + ``` +1. Review the diff and ensure the changes are expected and intentional. +2. Commit. + +#### What are snapshots? + +Snapshots are just a representation of some data structure generated by tests. Snapshots are stored in files and committed alongside the tests. When the tests are run, the data structure generated is compared with the snapshot on file. + +It's very easy to make a snapshot: + +```js +test( 'foobar test', () => { + const foobar = { foo: 'bar' }; + + expect( foobar ).toMatchSnapshot(); +} ); +``` + +This is the produced snapshot: + +```js +exports[`test foobar test 1`] = ` + Object { + "foo": "bar", + } +`; +``` + +You should never create or modify a snapshot directly, they are generated and updated by tests. + +#### Advantages + +* Trivial and concise to add tests. +* Protect against unintentional changes. +* Simple to work with. +* Reveal internal structures without running the application. + +#### Disadvantages + +* Not expressive. +* Only catch issues when changes are introduced. +* Are problematic for anything non-deterministic. + +#### Use cases + +Snapshot are mostly targeted at component testing. They make us conscious of changes to a component's structure which makes them _ideal_ for refactoring. If a snapshot is kept up to date over the course of a series of commits, the snapshot diffs record the evolution of a component's structure. Pretty cool 😎 + +```js +import { shallow } from 'enzyme'; +import SolarSystem from 'solar-system'; +import { Mars } from 'planets'; + +describe( 'SolarSystem', () => { + test( 'should render', () => { + const wrapper = shallow( ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should contain mars if planets is true', () => { + const wrapper = shallow( ); + + expect( wrapper ).toMatchSnapshot(); + expect( wrapper.find( Mars ) ).toHaveLength( 1 ); + } ); +} ); +``` + +Reducer tests are also a great fit for snapshots. They are often large, complex data structures that shouldn't change unexpectedly, exactly what snapshots excel at! + +#### Working with snapshots + +You might be blindsided by CI tests failing when snapshots don't match. You'll need to [update snapshots] if the changes are expected. The quick and dirty solution is to invoke Jest with `--updateSnapshot`. That can be done as follows: + +```sh +npm run test-unit -- --updateSnapshot --testPathPattern path/to/tests +``` + +`--testPathPattern` is not required, but specifying a path will speed things up by running a subset of tests. + +It's a great idea to keep `npm run test-unit:watch` running in the background as you work. Jest will run only the relevant tests for changed files, and when snapshot tests fail, just hit `u` to update a snapshot! + +#### Pain points + +Non-deterministic tests may not make consistent snapshots, so beware. When working with anything random, time-based, or otherwise non-deterministic, snapshots will be problematic. + +Connected components are tricky to work with. To snapshot a connected component you'll probably want to export the unconnected component: + +```js +// my-component.js +export { MyComponent }; +export default connect( mapStateToProps )( MyComponent ); + +// test/my-component.js +import { MyComponent } from '..'; +// run those MyComponent tests… +``` + +The connected props will need need to be manually provided. This is a good opportunity to audit the connected state. + +#### Best practices + +If you're starting a refactor, snapshots are quite nice, you can add them as the first commit on a branch and watch as they evolve. + +Snapshots themselves don't express anything about what we expect. Snapshots are best used in conjunction with other tests that describe our expectations, like in the example above: + +```js +test( 'should contain mars if planets is true', () => { + const wrapper = shallow( ); + + // Snapshot will catch unintended changes + expect( wrapper ).toMatchSnapshot(); + + // This is what we actually expect to find in our test + expect( wrapper.find( Mars ) ).toHaveLength( 1 ); +} ); +``` + +[`shallow`](http://airbnb.io/enzyme/docs/api/shallow.html) rendering is your friend: + +> Shallow rendering is useful to constrain yourself to testing a component as a unit, and to ensure that your tests aren't indirectly asserting on behavior of child components. + +It's tempting to snapshot deep renders, but that makes for huge snapshots. Additionally, deep renders no longer test a single component, but an entire tree. With `shallow`, we snapshot just the components that are directly rendered by the component we want to test. + +## End to end Testing + +If you're using the built-in [local environment](/docs/contributors/getting-started.md#local-environment), you can run the e2e tests locally using this command: + +```bash +npm run test-e2e +``` + +or interactively + +```bash +npm run test-e2e:watch +``` + +Sometimes it's useful to observe the browser while running tests. To do so you can use these environment variables: + +```bash +PUPPETEER_HEADLESS=false PUPPETEER_SLOWMO=80 npm run test-e2e:watch +``` + +If you're using a different setup, you can provide the base URL, username and password like this: + +```bash +WP_BASE_URL=http://localhost:8888 WP_USERNAME=admin WP_PASSWORD=password npm run test-e2e +``` + +### Core Block Testing + +Every core block is required to have at least one set of fixture files for its main save function and one for each deprecation. These fixtures test the parsing and serialization of the block. See [the e2e tests fixtures readme](/packages/e2e-tests/fixtures/blocks/README.md) for more information and instructions. + +## PHP Testing + +Tests for PHP use [PHPUnit](https://phpunit.de/) as the testing framework. If you're using the built-in [local environment](/docs/contributors/getting-started.md#local-environment), you can run the PHP tests locally using this command: + +```bash +npm run test-php +``` + +Code style in PHP is enforced using [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). It is recommended that you install PHP_CodeSniffer and the [WordPress Coding Standards for PHP_CodeSniffer](https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards#installation) ruleset using [Composer](https://getcomposer.org/). With Composer installed, run `composer install` from the project directory to install dependencies. The above `npm run test-php` will execute both unit tests and code linting. Code linting can be verified independently by running `npm run lint-php`. + +To run unit tests only, without the linter, use `npm run test-unit-php` instead. + +[snapshot testing]: https://jestjs.io/docs/en/snapshot-testing.html +[update snapshots]: https://jestjs.io/docs/en/snapshot-testing.html#updating-snapshots diff --git a/gb-src/docs/designers-developers/assets/fancy-quote-in-inspector.png b/gb-src/docs/designers-developers/assets/fancy-quote-in-inspector.png new file mode 100644 index 0000000000000..6bd8c06a9e397 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/fancy-quote-in-inspector.png differ diff --git a/gb-src/docs/designers-developers/assets/fancy-quote-with-style.png b/gb-src/docs/designers-developers/assets/fancy-quote-with-style.png new file mode 100644 index 0000000000000..31f38063a1f1d Binary files /dev/null and b/gb-src/docs/designers-developers/assets/fancy-quote-with-style.png differ diff --git a/gb-src/docs/designers-developers/assets/inspector.png b/gb-src/docs/designers-developers/assets/inspector.png new file mode 100644 index 0000000000000..9f143bdbcbfc7 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/inspector.png differ diff --git a/gb-src/docs/designers-developers/assets/js-tutorial-console-log-error.png b/gb-src/docs/designers-developers/assets/js-tutorial-console-log-error.png new file mode 100644 index 0000000000000..836a663484192 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/js-tutorial-console-log-error.png differ diff --git a/gb-src/docs/designers-developers/assets/js-tutorial-console-log-success.png b/gb-src/docs/designers-developers/assets/js-tutorial-console-log-success.png new file mode 100644 index 0000000000000..7b42853fb4064 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/js-tutorial-console-log-success.png differ diff --git a/gb-src/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png b/gb-src/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png new file mode 100644 index 0000000000000..1f27c36ce7595 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-block-settings-menu-item-screenshot.png b/gb-src/docs/designers-developers/assets/plugin-block-settings-menu-item-screenshot.png new file mode 100644 index 0000000000000..bc33b4fd205a6 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-block-settings-menu-item-screenshot.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-more-menu-item.png b/gb-src/docs/designers-developers/assets/plugin-more-menu-item.png new file mode 100644 index 0000000000000..23fa73db266a2 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-more-menu-item.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-post-publish-panel.png b/gb-src/docs/designers-developers/assets/plugin-post-publish-panel.png new file mode 100644 index 0000000000000..b4cd9318b68aa Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-post-publish-panel.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-post-status-info-location.png b/gb-src/docs/designers-developers/assets/plugin-post-status-info-location.png new file mode 100644 index 0000000000000..fa35405e7a099 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-post-status-info-location.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-pre-publish-panel.png b/gb-src/docs/designers-developers/assets/plugin-pre-publish-panel.png new file mode 100644 index 0000000000000..ea765f4f54319 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-pre-publish-panel.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-sidebar-closed-state.png b/gb-src/docs/designers-developers/assets/plugin-sidebar-closed-state.png new file mode 100644 index 0000000000000..025da900ffcdd Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-sidebar-closed-state.png differ diff --git a/gb-src/docs/designers-developers/assets/plugin-sidebar-more-menu-item.gif b/gb-src/docs/designers-developers/assets/plugin-sidebar-more-menu-item.gif new file mode 100644 index 0000000000000..851898484bc3c Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-sidebar-more-menu-item.gif differ diff --git a/gb-src/docs/designers-developers/assets/plugin-sidebar-open-state.png b/gb-src/docs/designers-developers/assets/plugin-sidebar-open-state.png new file mode 100644 index 0000000000000..f1c3781a500f0 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/plugin-sidebar-open-state.png differ diff --git a/gb-src/docs/designers-developers/assets/sidebar-style-and-controls.png b/gb-src/docs/designers-developers/assets/sidebar-style-and-controls.png new file mode 100644 index 0000000000000..725ddfdd87a4b Binary files /dev/null and b/gb-src/docs/designers-developers/assets/sidebar-style-and-controls.png differ diff --git a/gb-src/docs/designers-developers/assets/sidebar-up-and-running.png b/gb-src/docs/designers-developers/assets/sidebar-up-and-running.png new file mode 100644 index 0000000000000..12bc2947d48a3 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/sidebar-up-and-running.png differ diff --git a/gb-src/docs/designers-developers/assets/toolbar-text.png b/gb-src/docs/designers-developers/assets/toolbar-text.png new file mode 100644 index 0000000000000..8dbf503d50391 Binary files /dev/null and b/gb-src/docs/designers-developers/assets/toolbar-text.png differ diff --git a/gb-src/docs/designers-developers/assets/toolbar-with-custom-button.png b/gb-src/docs/designers-developers/assets/toolbar-with-custom-button.png new file mode 100644 index 0000000000000..3b22afaca318a Binary files /dev/null and b/gb-src/docs/designers-developers/assets/toolbar-with-custom-button.png differ diff --git a/gb-src/docs/designers-developers/designers/README.md b/gb-src/docs/designers-developers/designers/README.md new file mode 100644 index 0000000000000..a630847b37661 --- /dev/null +++ b/gb-src/docs/designers-developers/designers/README.md @@ -0,0 +1,3 @@ +# Designer Documentation + +For those designing blocks and other block editor integrations, this documentation will provide resources for creating beautiful and intuitive layouts. diff --git a/gb-src/docs/designers-developers/designers/animation.md b/gb-src/docs/designers-developers/designers/animation.md new file mode 100644 index 0000000000000..24948723e0d8c --- /dev/null +++ b/gb-src/docs/designers-developers/designers/animation.md @@ -0,0 +1,40 @@ +# Animation + +Animation can help reinforce a sense of hierarchy and spatial orientation. This document goes into principles you should follow when you add animation. + +## Principles + +### Point of Origin + +- Animation can help anchor an interface element. For example a menu can scale up from the button that opened it. +- Animation can help give a sense of place; for example a sidebar can animate in from the side, implying it was always hidden off-screen. +- Design your animations as if you're working with real-world materials. Imagine your user interface elements are made of real materials — when not on screen, where are they? Use animation to help express that. + +### Speed + +- Animations should never block a user interaction. They should be fast, almost always complete in less than 0.2 seconds. +- A user should not have to wait for an animation to finish before they can interact. +- Animations should be performant. Use `transform` CSS properties when you can, these render elements on the GPU, making them smooth. +- If an animation can't be made fast & performant, leave it out. + +### Simple + +- Don't bounce if the material isn't made of rubber. +- Don't rotate, fold, or animate on a curved path. Keep it simple. + +### Consistency + +In creating consistent animations, we have to establish physical rules for how elements behave when animated. When all animations follow these rules, they feel consistent, related, and predictable. An animation should match user expectations, if it doesn't, it's probably not the right animation for the job. + +Reuse animations if one already exists for your task. + +## Accessibility Considerations + +- Animations should be subtle. Be cognizent of users with [vestibular disorders triggered by motion](https://www.ncbi.nlm.nih.gov/pubmed/29017000). +- Don't animate elements that are currently reporting content to adaptive technology (e.g., an `aria-live` region that's receiving updates). This can cause confusion wherein the technology tries to parse a region that's actively changing. +- Avoid animations that aren't directly triggered by user behaviors. +- Whenever possible, ensure that animations respect the OS-level "Reduce Motion" settings. This can be done by utilizing the [`prefers-reduce-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query. Gutenberg includes a `@reduce-motion` mixin for this, to be used alongside rules that include a CSS `animate` property. + +## Inventory of Reused Animations + +The generic `Animate` component is used to animate different parts of the interface. See [the component documentation](/packages/components/src/animate/README.md) for more details about the available animations. diff --git a/gb-src/docs/designers-developers/designers/assets/advanced-settings-do.png b/gb-src/docs/designers-developers/designers/assets/advanced-settings-do.png new file mode 100644 index 0000000000000..c589ace4c1348 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/advanced-settings-do.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/block-controls-do.png b/gb-src/docs/designers-developers/designers/assets/block-controls-do.png new file mode 100644 index 0000000000000..86f2019638fe0 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/block-controls-do.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/block-controls-dont.png b/gb-src/docs/designers-developers/designers/assets/block-controls-dont.png new file mode 100644 index 0000000000000..301a521154641 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/block-controls-dont.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/block-descriptions-do.png b/gb-src/docs/designers-developers/designers/assets/block-descriptions-do.png new file mode 100644 index 0000000000000..53d6a443c7890 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/block-descriptions-do.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/block-descriptions-dont.png b/gb-src/docs/designers-developers/designers/assets/block-descriptions-dont.png new file mode 100644 index 0000000000000..9225cb5d8575a Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/block-descriptions-dont.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/blocks-do.png b/gb-src/docs/designers-developers/designers/assets/blocks-do.png new file mode 100644 index 0000000000000..bd79797dfea12 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/blocks-do.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/blocks-dont.png b/gb-src/docs/designers-developers/designers/assets/blocks-dont.png new file mode 100644 index 0000000000000..2c69f1ada0921 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/blocks-dont.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/placeholder-do.png b/gb-src/docs/designers-developers/designers/assets/placeholder-do.png new file mode 100644 index 0000000000000..dc5f1327d429a Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/placeholder-do.png differ diff --git a/gb-src/docs/designers-developers/designers/assets/placeholder-dont.png b/gb-src/docs/designers-developers/designers/assets/placeholder-dont.png new file mode 100644 index 0000000000000..4f313ccee5ac1 Binary files /dev/null and b/gb-src/docs/designers-developers/designers/assets/placeholder-dont.png differ diff --git a/gb-src/docs/designers-developers/designers/block-design.md b/gb-src/docs/designers-developers/designers/block-design.md new file mode 100644 index 0000000000000..7ee481637bb22 --- /dev/null +++ b/gb-src/docs/designers-developers/designers/block-design.md @@ -0,0 +1,203 @@ +# Block Design + +The following are best practices for designing a new block, with recommendations and detailed descriptions of existing blocks to illustrate our approach to creating blocks. + +## Best Practices + +### The primary interface for a block is the content area of the block + +Since the block itself represents what will actually appear on the site, interaction here hews closest to the principle of direct manipulation and will be most intuitive to the user. This should be thought of as the primary interface for adding and manipulating content and adjusting how it is displayed. There are two ways of interacting here: + +1. The placeholder content in the content area of the block can be thought of as a guide or interface for users to follow a set of instructions or “fill in the blanks”. For example, a block that embeds content from a 3rd-party service might contain controls for signing in to that service in the placeholder. +2. After the user has added content, selecting the block can reveal additional controls to adjust or edit that content. For example, a signup block might reveal a control for hiding/showing subscriber count. However, this should be done in minimal ways, so as to avoid dramatically changing the size and display of a block when a user selects it (this could be disorienting or annoying). + +### The Block Toolbar is a secondary place for required options & controls + +Basic block settings won’t always make sense in the context of the placeholder/content UI. As a secondary option, options that are critical to the functionality of a block can live in the block toolbar. The Block Toolbar is still highly contextual and visible on all screen sizes. One notable constraint with the Block Toolbar is that it is icon-based UI, so any controls that live in the Block Toolbar need to be ones that can effectively be communicated via an icon or icon group. + +### The Settings Sidebar should only be used for advanced, tertiary controls + +The Settings Sidebar is not visible by default on a small / mobile screen, and may also be collapsed in a desktop view. Therefore, it should not be relied on for anything that is necessary for the basic operation of the block. Pick good defaults, make important actions available in the block toolbar, and think of the Settings Sidebar as something that most users should not need to open. + +In addition, use sections and headers in the Settings Sidebar if there are more than a handful of options, in order to allow users to easily scan and understand the options available. + +Each Settings Sidebar comes with an "Advanced" section by default. This area houses an "Additional CSS Class" field, and should be used to house other power user controls. + +## Setup state vs. live preview state + +Setup states, sometimes referred to as "placeholders", can be used to walk users through an initial process before showing the live preview state of the block. The setup process gathers information from the user that is needed to render the block. A block’s setup state is indicated with a grey background to provide clear differentiation for the user. Not all blocks have setup states — for example, the Paragraph block. + +![An example of a gallery block’s setup state on a grey background](https://make.wordpress.org/design/files/2018/12/gallery-setup.png) + +A setup state is **not** necessary if: + +- You can provide good default content in the block that will meet most people’s needs. +- That default content is easy to edit and customize. + +Use a setup state if: + +- There isn’t a clear default state that would work for most users. +- You need to gather input from the user that doesn’t have a 1-1 relationship with the live preview of the block (for example, if you need the user to input an API key to render content). +- You need more information from the user in order to render useful default content. + +For blocks that do have setup states, once the user has gone through the setup process, the placeholder is replaced with the live preview state of that block. + +![An example of the image gallery’s live preview state](https://make.wordpress.org/design/files/2018/12/gallery-live-preview.png) + +When the block is selected, additional controls may be revealed to customize the block’s contents. For example, when the image gallery is selected, it reveals controls to remove or add images. + +![An example of additional controls being revealed on selection of a block.](https://make.wordpress.org/design/files/2018/12/gallery-additional-controls.png) + +In most cases, a block’s setup state is only shown once and then further customization is done via the live preview state. However, in some cases it might be desirable to allow the user to return to the setup state — for example, if all the block content has been deleted or via a link from the block’s toolbar or sidebar. + +## Do's and Don'ts + +### Block Identification + +A block should have a straightforward, short name so users can easily find it in the Block Library. A block named "YouTube" is easy to find and understand. The same block, named "Embedded Video (YouTube)", would be less clear and harder to find in the Block Library. + +When referring to a block in documentation or UI, use title case for the block title, and lowercase for the "block" descriptor. For example: + +- Paragraph block +- Latest Posts block +- Media & Text block + +Blocks should have an identifying icon, ideally using a single color. Try to avoid using the same icon used by an existing block. The core block icons are based on [Material Design Icons](https://material.io/tools/icons/). Look to that icon set, or to [Dashicons](https://developer.wordpress.org/resource/dashicons/) for style inspiration. + +![A screenshot of the Block Library with concise block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/blocks-do.png) +**Do:** +Use concise block names. + +![A screenshot of the Block Library with long, multi-line block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/blocks-dont.png) +**Don't:** +Avoid long, multi-line block names. + +### Block Description + +Every block should include a description that clearly explains the block's function. The description will display in the Settings Sidebar. + +You can add a description by using the description attribute in the [registerBlockType function](/docs/designers-developers/developers/block-api/block-registration/). + +Stick to a single imperative sentence with an action + subject format. Examples: + +- Start with the building block of all narrative. +- Introduce new sections and organize content to help visitors (and search engines) understand the structure of your content. +- Create a bulleted or numbered list. + +![A screenshot of a short block description](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-descriptions-do.png) +**Do:** +Use a short, simple, block description. + +![A screenshot of a long block description that includes branding](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-descriptions-dont.png) +**Don't:** +Avoid long descriptions and branding. + +### Placeholders + +If your block requires a user to configure some options before you can display it, you should provide an instructive placeholder state. + +![A screenshot of the Gallery block's placeholder](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/placeholder-do.png) +**Do:** +Provide an instructive placeholder state. + +![An example Gallery block placeholder but with intense, distracting colors and no instructions](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/placeholder-dont.png) +**Don't:** +Avoid branding and relying on the title alone to convey instructions. + +### Selected and Unselected States + +When unselected, your block should preview its content as closely to the front-end output as possible. + +When selected, your block may surface additional options like input fields or buttons to configure the block directly, especially when they are necessary for basic operation. + +![A Google Maps block with inline, always-accessible controls required for the block to function](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-controls-do.png) +**Do:** +For controls that are essential the the operation of the block, provide them directly in inside the block edit view. + +![A Google Maps block with essential controls moved to the sidebar where they can be contextually hidden](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-controls-dont.png) +**Don't:** +Do not put controls that are essential to the block in the sidebar, or the block will appear non-functional to mobile users, or desktop users who have dismissed the sidebar. + +### Advanced Block Settings + +The “Block” tab of the Settings Sidebar can contain additional block options and configuration. Keep in mind that a user can dismiss the sidebar and never use it. You should not put critical options in the Sidebar. + +![A screenshot of the Paragraph block's advanced settings in the sidebar](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/advanced-settings-do.png) +**Do:** +Because the Drop Cap feature is not necessary for the basic operation of the block, you can put it to the Block tab as optional configuration. + +### Consider mobile + +Check how your block looks, feels, and works on as many devices and screen sizes as you can. + +### Support Gutenberg's dark background editor scheme + +Check how your block looks with [dark backgrounds](/docs/designers-developers/developers/themes/theme-support.md#dark-backgrounds) in the editor. + +## Examples + +To demonstrate some of these practices, here are a few annotated examples of default Gutenberg blocks: + +### Paragraph + +The most basic unit of the editor. The Paragraph block is a simple input field. + +![Paragraph block](https://cldup.com/HVJe5bGZ8H-3000x3000.png) + +### Placeholder: + +- Simple placeholder text that reads “Start writing or type / to choose a block”. The placeholder disappears when the block is selected. + +### Selected state: + +- Block Toolbar: Has a switcher to perform transformations to headings, etc. +- Block Toolbar: Has basic text alignments +- Block Toolbar: Has inline formatting options, bold, italic, strikethrough and link + +### Image + +Basic image block. + +![Image block placeholder](https://cldup.com/w6FNywNsj1-3000x3000.png) + +### Placeholder: + +- A generic gray placeholder block with options to upload an image, drag and drop an image directly on it, or pick an image from the media library. + +### Selected state: + +- Block Toolbar: Alignments, including wide and full-width if the theme supports it. +- Block Toolbar: Edit Image, to open the Media Library +- Block Toolbar: Link button +- When an image is uploaded, a caption input field appears with a “Write caption…” placeholder text below the image: + +![Image Block](https://cldup.com/6YYXstl_xX-3000x3000.png) + +### Block settings: + +- Has description: “They're worth 1,000 words! Insert a single image.” +- Has options for changing or adding alt text and adding additional custom CSS classes. + +_Future improvements to the Image block could include getting rid of the media modal, in place of letting users select images directly from the placeholder itself. In general, try to avoid modals._ + +### Latest Post + +![Latest Post Block](https://cldup.com/8lyAByDpy_-3000x3000.png) + +### Placeholder: + +Has no placeholder, as it works immediately upon insertion. The default inserted state shows the last 5 posts. + +### Selected state: + +- Block Toolbar: Alignments +- Block Toolbar: Options for picking list view or grid view + +_Note that the Block Toolbar does not include the Block Chip in this case, since there are no similar blocks to switch to._ + +### Block settings: + +- Has description: “Display a list of your most recent posts.” +- Has options for post order, narrowing the list by category, changing the default number of posts to show, and showing the post date. + +_Latest Posts is fully functional as soon as it’s inserted, because it comes with good defaults._ diff --git a/gb-src/docs/designers-developers/designers/design-patterns.md b/gb-src/docs/designers-developers/designers/design-patterns.md new file mode 100644 index 0000000000000..bf06df6da5e9d --- /dev/null +++ b/gb-src/docs/designers-developers/designers/design-patterns.md @@ -0,0 +1,65 @@ +# Patterns + +## Basic Editor Interface + +The block editor’s general layout uses on a bar at the top, with content below. + +![Editor Interface](https://cldup.com/VWA_jMcIRw-3000x3000.png) + +The **Toolbar** contains document-level actions: Editor mode, save status, global actions for undo/redo/insert, the settings toggle, and publish options. + +The **Content Area** contains the document itself. + +The **Settings Sidebar** contains additional settings for the document (tags, categories, schedule etc.) and for blocks in the “Block” tab. A cog button in the toolbar hides the Settings Sidebar, allowing the user to enjoy a more immersive writing experience. On small screens, the sidebar is hidden by default. + +## The Block Interface + +The block itself is the most basic unit of the editor. Generally speaking, everything is a block. Users build posts and pages using blocks, mimicking the vertical flow of the underlying HTML markup. + +By surfacing each section of the document as a manipulatable block, we surface block-specific features contextually. This is inspired by desktop app conventions, and allows for a breadth of advanced features without weighing down the UI. + +A selected block shows a number of contextual actions: + +![Block Interface](https://cldup.com/3tQqIncKPB-3000x3000.png) + +The block interface has basic actions. The block editor aims for good, common defaults, so users should be able to create a complete document without actually needing the advanced actions in the Settings Sidebar. + +**The Block Toolbar** highlights commonly-used actions. The **Block Chip** lives in the block toolbar, and contains high-level controls for the selected block. It primarily allows users to transform a block into another type of compatible block. Some blocks also use the block chip to users to choose from a set of alternate block styles. + +The **Block Formatting** options let users adjust block-level settings, and the **Inline Formatting** options allow adjustments to elements inside the block. When a block is long, the block toolbar pins itself to the top of the screen as the user scrolls down the page. + +Blocks can be moved up and down via the **Block Mover** icons on the left. Additional block actions are available on the right via an ellipsis menu: deleting and duplicating blocks, as well as **advanced actions** like “Edit as HTML” and “Convert to Reusable Block.” + +An unselected block does not show the block toolbar or any other contextual controls. In effect, an unselected block is a preview of the content itself: + +![Unselected Block](https://cldup.com/DH9HZnEgwH-3000x3000.png) + +Please note that selection and focus can be different. An image block can be selected while the focus is on the caption field. + +## Settings Sidebar + +![Settings Sidebar](https://cldup.com/iAqrn6Gc8o-3000x3000.png) + +The sidebar has two tabs, Document and Block: + +- The **Document Tab** shows metadata and settings for the post or page being edited. +- The **Block Tab** shows metadata and settings for the currently selected block. + +Each tab has sets of editable fields (**Sidebar Sections**) that users can toggle open or closed. + +If a block requires advanced configuration, those settings should live in the Settings sidebar (Editor block settings can also be reached directly by clicking the cog icon next to a block). Don’t put anything in the sidebar block tab that is necessary for the basic operation of your block; your user might dismiss the sidebar for an immersive writing experience. Pick good defaults, and make important actions available in the block toolbar. + +Actions that could go in the block tab of the sidebar could be: + +- Drop cap, for text +- Number of columns for galleries +- Number of posts, or category, in the “Latest Posts” block +- Any configuration that you don’t need access to in order to perform basic tasks + +## Block Library + +![Block Library](https://cldup.com/7QoQIoLk-A-3000x3000.png) + +The **Block Library** appears when someone inserts a block, whether via the toolbar, or contextually within the content area. Inside, blocks are organized into expandable sections. The block library’s search bar auto-filters the list of blocks as the user types. Users can choose a block by selecting the **Block Button** or the **Block Name**. + +**Parent Blocks** (Blocks that contain children blocks) are represented by a layered block button. diff --git a/gb-src/docs/designers-developers/designers/design-resources.md b/gb-src/docs/designers-developers/designers/design-resources.md new file mode 100644 index 0000000000000..eaa82d16356c3 --- /dev/null +++ b/gb-src/docs/designers-developers/designers/design-resources.md @@ -0,0 +1,51 @@ +# Resources + +## Figma +The [WordPress Design team](https://make.wordpress.org/design/) uses [Figma](https://www.figma.com/) to collaborate and share work. If you'd like to contribute, join the [#design channel](https://app.slack.com/client/T024MFP4J/C02S78ZAL) in [Slack](https://make.wordpress.org/chat/) and ask the team to set you up with a free Figma account. This will give you access to a helpful library of components used in WordPress. They are stable, fully supported, up to date, and ready for use in designs and prototypes. + +### How to contribute + +### Resources for learning how to use Figma +[Getting started with Figma](https://help.figma.com/category/9-getting-started) + +[Top Online Tutorials to Learn Figma for UI/UX Design](https://medium.com/quick-design/top-online-tutorials-to-learn-figma-for-ui-ux-design-4e9c6721a72d) + +[Take a Tour Around Figma](https://help.figma.com/article/12-getting-familiar-with-figma) + +### Learning how to use files and projects +[Getting started with Figma files and projects](https://help.figma.com/article/298-getting-started-with-files-and-projects) + +[What are files?](https://help.figma.com/article/298-getting-started-with-files-and-projects#files) + +[What are projects?](https://help.figma.com/article/298-getting-started-with-files-and-projects#projects) + +[Video tutorial](https://www.youtube.com/watch?v=c5HS6smhq2E) + +[FAQ](https://help.figma.com/article/298-getting-started-with-files-and-projects#faq) + +### Learning how to use components +[Getting started with components](https://help.figma.com/article/66-components) + +[What are components?](https://help.figma.com/article/66-components#components) + +[Video tutorial](https://help.figma.com/article/66-components#videos) + +### Learning how to use WordPress Figma libraries +**How to turn on the WordPress Components library in Figma** + +![How to turn on Figma libraries gif](https://wordpress.org/gutenberg/files/2019/08/figma-howtoturnonlibraries.gif) + +1. Click the **Team Library** icon in the **Assets** Panel: + +![Hovering over the team library icon](https://wordpress.org/gutenberg/files/2019/08/figma-turn-on-libraries-e1564770916643.png) + +2. The **Library** modal will open and allow you to view a list of available libraries. Toggle to _Enable_ or _Disable_ a specific library: + +![Switching on the WordPress components libray in Figma](https://wordpress.org/gutenberg/files/2019/08/figma-libraries-e1564770879415.png) + +**How to refine or contribute to the WordPress components React library _(Coming soon)_** + +WordPress components in Figma mirror the live React components. Documentation for how to refine or contribute to WordPress components in React is coming soon. + + +If you have questions, please don’t hesitate to ask in the #design channel on the WordPress community Slack. diff --git a/gb-src/docs/designers-developers/developers/README.md b/gb-src/docs/designers-developers/developers/README.md new file mode 100644 index 0000000000000..ec19e12703bac --- /dev/null +++ b/gb-src/docs/designers-developers/developers/README.md @@ -0,0 +1,45 @@ +# Developer Documentation + +The new editor is highly flexible, like most of WordPress. You can build custom blocks, modify the editor's appearance, add special plugins, and much more. + +## Creating Blocks + +The editor is about blocks, and the main extensibility API is the Block API. It allows you to create your own static blocks, [Dynamic Blocks](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md) ( rendered on the server ) and also blocks capable of saving data to Post Meta for more structured content. + +If you want to learn more about block creation, the [Blocks Tutorial](/docs/designers-developers/developers/tutorials/block-tutorial/readme.md) is the best place to start. + +## Extending Blocks + +It is also possible to modify the behavior of existing blocks or even remove them completely using filters. + +Learn more in the [Block Filters](/docs/designers-developers/developers/filters/block-filters.md) section. + +## Extending the Editor UI + +Extending the editor UI can be accomplished with the `registerPlugin` API, allowing you to define all your plugin's UI elements in one place. + +Refer to the [Plugins](/packages/plugins/README.md) and [Edit Post](/packages/edit-post/README.md) section for more information. + +You can also filter certain aspects of the editor; this is documented on the [Editor Filters](/docs/designers-developers/developers/filters/editor-filters.md) page. + +## Meta Boxes + +Porting PHP meta boxes to blocks or sidebar plugins is highly encouraged, learn how through these [meta data tutorials](/docs/designers-developers/developers/tutorials/metabox/readme.md). + +See how the new editor [supports existing Meta Boxes](/docs/designers-developers/developers/backward-compatibility/meta-box.md). + +## Theme Support + +By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or rely on defaults. + +There are some advanced block features which require opt-in support in the theme. See [theme support](/docs/designers-developers/developers/themes/theme-support.md). + +## Autocomplete + +Autocompleters within blocks may be extended and overridden. Learn more about the [autocomplete](/docs/designers-developers/developers/filters/autocomplete-filters.md) filters. + +## Block Parsing and Serialization + +Posts in the editor move through a couple of different stages between being stored in `post_content` and appearing in the editor. Since the blocks themselves are data structures that live in memory it takes a parsing and serialization step to transform out from and into the stored format in the database. + +Customizing the parser is an advanced topic that you can learn more about in the [Extending the Parser](/docs/designers-developers/developers/filters/parser-filters.md) section. diff --git a/gb-src/docs/designers-developers/developers/accessibility.md b/gb-src/docs/designers-developers/developers/accessibility.md new file mode 100644 index 0000000000000..ab6c0d0066b1d --- /dev/null +++ b/gb-src/docs/designers-developers/developers/accessibility.md @@ -0,0 +1,17 @@ +# Accessibility + +Accessibility documentation for developers working on the Gutenberg Project. + +For more information on accessibility and WordPress see the [Make WordPress Accessibility Handbook](https://make.wordpress.org/accessibility/handbook/) and the [Accessibility Team section](https://make.wordpress.org/accessibility/). + +## Landmark Regions + +It is a best practice to include ALL content on the page in landmarks, so that screen reader users who rely on them to navigate from section to section do not lose track of content. + +For setting up navigation between different regions, see the [navigateRegions package](/packages/components/src/higher-order/navigate-regions/README.md) for additional documentation. + +Read more regarding landmark design from W3C: + +- [General Principles of Landmark Design](https://www.w3.org/TR/wai-aria-practices-1.1/#general-principles-of-landmark-design) +- [ARIA Landmarks Example](https://www.w3.org/TR/wai-aria-practices/examples/landmarks/) +- [HTML5 elements that by default define ARIA landmarks](https://www.w3.org/TR/wai-aria-practices/examples/landmarks/HTML5.html) diff --git a/gb-src/docs/designers-developers/developers/backward-compatibility/README.md b/gb-src/docs/designers-developers/developers/backward-compatibility/README.md new file mode 100644 index 0000000000000..bd559617c5b17 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/backward-compatibility/README.md @@ -0,0 +1 @@ +# Backward Compatibility diff --git a/gb-src/docs/designers-developers/developers/backward-compatibility/deprecations.md b/gb-src/docs/designers-developers/developers/backward-compatibility/deprecations.md new file mode 100644 index 0000000000000..9421833ba0290 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/backward-compatibility/deprecations.md @@ -0,0 +1,272 @@ +# Deprecations + +For features included in the Gutenberg plugin, the deprecation policy is intended to support backward compatibility for two minor plugin releases, when possible. Features and code included in a stable release of WordPress are not included in this deprecation timeline, and are instead subject to the [versioning policies of the WordPress project](https://make.wordpress.org/core/handbook/about/release-cycle/version-numbering/). The current deprecations are listed below and are grouped by _the version at which they will be removed completely_. If your plugin depends on these behaviors, you must update to the recommended alternative before the noted version. + +## 5.5.0 + +- The PHP function `gutenberg_init` has been removed. +- The PHP function `is_gutenberg_page` has been removed. Use [`WP_Screen::is_block_editor`](https://developer.wordpress.org/reference/classes/wp_screen/is_block_editor/) instead. +- The PHP function `the_gutenberg_project` has been removed. +- The PHP function `gutenberg_default_post_format_template` has been removed. +- The PHP function `gutenberg_get_available_image_sizes` has been removed. +- The PHP function `gutenberg_get_autosave_newer_than_post_save` has been removed. +- The PHP function `gutenberg_editor_scripts_and_styles` has been removed. + +## 5.4.0 + +- The PHP function `gutenberg_load_plugin_textdomain` has been removed. +- The PHP function `gutenberg_get_jed_locale_data` has been removed. +- The PHP function `gutenberg_load_locale_data` has been removed. + +## 5.3.0 + +- The PHP function `gutenberg_redirect_to_classic_editor_when_saving_posts` has been removed. +- The PHP function `gutenberg_revisions_link_to_editor` has been removed. +- The PHP function `gutenberg_remember_classic_editor_when_saving_posts` has been removed. +- The PHP function `gutenberg_can_edit_post_type` has been removed. Use [`use_block_editor_for_post_type`](https://developer.wordpress.org/reference/functions/use_block_editor_for_post_type/) instead. +- The PHP function `gutenberg_can_edit_post` has been removed. Use [`use_block_editor_for_post`](https://developer.wordpress.org/reference/functions/use_block_editor_for_post/) instead. + +## 5.2.0 + +- The PHP function `gutenberg_parse_blocks` has been removed. Use [`parse_blocks`](https://developer.wordpress.org/reference/functions/parse_blocks/) instead. +- The PHP function `get_dynamic_blocks_regex` has been removed. +- The PHP function `gutenberg_render_block` has been removed. Use [`render_block`](https://developer.wordpress.org/reference/functions/render_block/) instead. +- The PHP function `strip_dynamic_blocks` has been removed. For use in excerpt preparation, consider [`excerpt_remove_blocks`](https://developer.wordpress.org/reference/functions/excerpt_remove_blocks/) instead. +- The PHP function `strip_dynamic_blocks_add_filter` has been removed. +- The PHP function `strip_dynamic_blocks_remove_filter` has been removed. +- The PHP function `gutenberg_post_has_blocks` has been removed. Use [`has_blocks`](https://developer.wordpress.org/reference/functions/has_blocks/) instead. +- The PHP function `gutenberg_content_has_blocks` has been removed. Use [`has_blocks`](https://developer.wordpress.org/reference/functions/has_blocks/) instead. +- The PHP function `gutenberg_register_rest_routes` has been removed. +- The PHP function `gutenberg_add_taxonomy_visibility_field` has been removed. +- The PHP function `gutenberg_get_taxonomy_visibility_data` has been removed. +- The PHP function `gutenberg_add_permalink_template_to_posts` has been removed. +- The PHP function `gutenberg_add_block_format_to_post_content` has been removed. +- The PHP function `gutenberg_add_target_schema_to_links` has been removed. +- The PHP function `gutenberg_register_post_prepare_functions` has been removed. +- The PHP function `gutenberg_silence_rest_errors` has been removed. +- The PHP function `gutenberg_filter_post_type_labels` has been removed. +- The PHP function `gutenberg_preload_api_request` has been removed. Use [`rest_preload_api_request`](https://developer.wordpress.org/reference/functions/rest_preload_api_request/) instead. +- The PHP function `gutenberg_remove_wpcom_markdown_support` has been removed. +- The PHP function `gutenberg_add_gutenberg_post_state` has been removed. +- The PHP function `gutenberg_bulk_post_updated_messages` has been removed. +- The PHP function `gutenberg_kses_allowedtags` has been removed. +- The PHP function `gutenberg_add_responsive_body_class` has been removed. +- The PHP function `gutenberg_add_edit_link_filters` has been removed. +- The PHP function `gutenberg_add_edit_link` has been removed. +- The PHP function `gutenberg_block_bulk_actions` has been removed. +- The PHP function `gutenberg_replace_default_add_new_button` has been removed. +- The PHP function `gutenberg_content_block_version` has been removed. Use [`block_version`](https://developer.wordpress.org/reference/functions/block_version/) instead. +- The PHP function `gutenberg_get_block_categories` has been removed. Use [`get_block_categories`](https://developer.wordpress.org/reference/functions/get_block_categories/) instead. +- The PHP function `register_tinymce_scripts` has been removed. Use [`wp_register_tinymce_scripts`](https://developer.wordpress.org/reference/functions/wp_register_tinymce_scripts/) instead. +- The PHP function `gutenberg_register_post_types` has been removed. +- The `gutenberg` theme support option has been removed. Use [`align-wide`](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#wide-alignment) instead. +- The PHP function `gutenberg_prepare_blocks_for_js` has been removed. Use [`get_block_editor_server_block_settings`](https://developer.wordpress.org/reference/functions/get_block_editor_server_block_settings/) instead. +- The PHP function `gutenberg_load_list_reusable_blocks` has been removed. +- The PHP function `_gutenberg_utf8_split` has been removed. Use `_mb_substr` instead. +- The PHP function `gutenberg_disable_editor_settings_wpautop` has been removed. +- The PHP function `gutenberg_add_rest_nonce_to_heartbeat_response_headers` has been removed. +- The PHP function `gutenberg_check_if_classic_needs_warning_about_blocks` has been removed. +- The PHP function `gutenberg_warn_classic_about_blocks` has been removed. +- The PHP function `gutenberg_show_privacy_policy_help_text` has been removed. +- The PHP function `gutenberg_common_scripts_and_styles` has been removed. Use [`wp_common_block_scripts_and_styles`](https://developer.wordpress.org/reference/functions/wp_common_block_scripts_and_styles/) instead. +- The PHP function `gutenberg_enqueue_registered_block_scripts_and_styles` has been removed. Use [`wp_enqueue_registered_block_scripts_and_styles`](https://developer.wordpress.org/reference/functions/wp_enqueue_registered_block_scripts_and_styles/) instead. +- The PHP function `gutenberg_meta_box_save` has been removed. +- The PHP function `gutenberg_meta_box_save_redirect` has been removed. +- The PHP function `gutenberg_filter_meta_boxes` has been removed. +- The PHP function `gutenberg_intercept_meta_box_render` has been removed. +- The PHP function `gutenberg_override_meta_box_callback` has been removed. +- The PHP function `gutenberg_show_meta_box_warning` has been removed. +- The PHP function `the_gutenberg_metaboxes` has been removed. Use [`the_block_editor_meta_boxes`](https://developer.wordpress.org/reference/functions/the_block_editor_meta_boxes/) instead. +- The PHP function `gutenberg_meta_box_post_form_hidden_fields` has been removed. Use [`the_block_editor_meta_box_post_form_hidden_fields`](https://developer.wordpress.org/reference/functions/the_block_editor_meta_box_post_form_hidden_fields/) instead. +- The PHP function `gutenberg_toggle_custom_fields` has been removed. +- The PHP function `gutenberg_collect_meta_box_data` has been removed. Use [`register_and_do_post_meta_boxes`](https://developer.wordpress.org/reference/functions/register_and_do_post_meta_boxes/) instead. +- `window._wpLoadGutenbergEditor` has been removed. Use `window._wpLoadBlockEditor` instead. Note: This is a private API, not intended for public use. It may be removed in the future. +- The PHP function `gutenberg_get_script_polyfill` has been removed. Use [`wp_get_script_polyfill`](https://developer.wordpress.org/reference/functions/wp_get_script_polyfill/) instead. +- The PHP function `gutenberg_add_admin_body_class` has been removed. Use the `.block-editor-page` class selector in your stylesheets if you need to scope styles to the block editor screen. + +## 4.5.0 +- `Dropdown.refresh()` has been deprecated as the contained `Popover` is now automatically refreshed. +- `wp.editor.PostPublishPanelToggle` has been deprecated in favor of `wp.editor.PostPublishButton`. + +## 4.4.0 + +- `wp.date.getSettings` has been removed. Please use `wp.date.__experimentalGetSettings` instead. +- `wp.compose.remountOnPropChange` has been removed. +- The following editor store actions have been removed: `createNotice`, `removeNotice`, `createSuccessNotice`, `createInfoNotice`, `createErrorNotice`, `createWarningNotice`. Use the equivalent actions by the same name from the `@wordpress/notices` module. +- The id prop of wp.nux.DotTip has been removed. Please use the tipId prop instead. +- `wp.blocks.isValidBlock` has been removed. Please use `wp.blocks.isValidBlockContent` instead but keep in mind that the order of params has changed. +- `wp.data` `registry.registerReducer` has been deprecated. Use `registry.registerStore` instead. +- `wp.data` `registry.registerSelectors` has been deprecated. Use `registry.registerStore` instead. +- `wp.data` `registry.registerActions` has been deprecated. Use `registry.registerStore` instead. +- `wp.data` `registry.registerResolvers` has been deprecated. Use `registry.registerStore` instead. +- `moment` has been removed from the public API for the date module. + +## 4.3.0 + +- `isEditorSidebarPanelOpened` selector (`core/edit-post`) has been removed. Please use `isEditorPanelEnabled` instead. +- `toggleGeneralSidebarEditorPanel` action (`core/edit-post`) has been removed. Please use `toggleEditorPanelOpened` instead. +- `wp.components.PanelColor` component has been removed. Please use `wp.editor.PanelColorSettings` instead. +- `wp.editor.PanelColor` component has been removed. Please use `wp.editor.PanelColorSettings` instead. + +## 4.2.0 + +- Writing resolvers as async generators has been removed. Use the controls plugin instead. +- `wp.components.AccessibleSVG` component has been removed. Please use `wp.components.SVG` instead. +- The `wp.editor.UnsavedChangesWarning` component no longer accepts a `forceIsDirty` prop. +- `setActiveMetaBoxLocations` action (`core/edit-post`) has been removed. +- `initializeMetaBoxState` action (`core/edit-post`) has been removed. +- `wp.editPost.initializeEditor` no longer returns an object. Use the `setActiveMetaBoxLocations` action (`core/edit-post`) in place of the existing object's `initializeMetaBoxes` function. +- `setMetaBoxSavedData` action (`core/edit-post`) has been removed. +- `getMetaBoxes` selector (`core/edit-post`) has been removed. Use `getActiveMetaBoxLocations` selector (`core/edit-post`) instead. +- `getMetaBox` selector (`core/edit-post`) has been removed. Use `isMetaBoxLocationActive` selector (`core/edit-post`) instead. +- Attribute type coercion has been removed. Omit the source to preserve type via serialized comment demarcation. +- `mediaDetails` in object passed to `onFileChange` callback of `wp.editor.mediaUpload`. Please use `media_details` property instead. +- `wp.components.CodeEditor` has been removed. Used `wp.codeEditor` directly instead. +- `wp.blocks.setUnknownTypeHandlerName` has been removed. Please use `setFreeformContentHandlerName` and `setUnregisteredTypeHandlerName` instead. +- `wp.blocks.getUnknownTypeHandlerName` has been removed. Please use `getFreeformContentHandlerName` and `getUnregisteredTypeHandlerName` instead. +- The Reusable Blocks Data API was marked as experimental as it's subject to change in the future. + +## 4.1.0 + +- `wp.data.dispatch( 'core/editor' ).checkTemplateValidity` has been removed. Validity is verified automatically upon block reset. + +## 4.0.0 + +- `wp.editor.RichTextProvider` has been removed. Please use `wp.data.select( 'core/editor' )` methods instead. +- `wp.components.Draggable` as a DOM node drag handler has been removed. Please, use `wp.components.Draggable` as a wrap component for your DOM node drag handler. +- `wp.i18n.getI18n` has been removed. Use `__`, `_x`, `_n`, or `_nx` instead. +- `wp.i18n.dcnpgettext` has been removed. Use `__`, `_x`, `_n`, or `_nx` instead. + +## 3.9.0 + +- RichText `getSettings` prop has been removed. The `unstableGetSettings` prop is available if continued use is required. Unstable APIs are strongly discouraged to be used, and are subject to removal without notice. +- RichText `onSetup` prop has been removed. The `unstableOnSetup` prop is available if continued use is required. Unstable APIs are strongly discouraged to be used, and are subject to removal without notice. +- `wp.editor.getColorName` has been removed. Please use `wp.editor.getColorObjectByColorValue` instead. +- `wp.editor.getColorClass` has been renamed. Please use `wp.editor.getColorClassName` instead. +- `value` property in color objects passed by `wp.editor.withColors` has been removed. Please use color property instead. +- The Subheading block has been removed. Please use the Paragraph block instead. +- `wp.blocks.getDefaultBlockForPostFormat` has been removed. + +## 3.8.0 + + - `wp.components.withContext` has been removed. Please use `wp.element.createContext` instead. See: https://reactjs.org/docs/context.html. + - `wp.coreBlocks.registerCoreBlocks` has been removed. Please use `wp.blockLibrary.registerCoreBlocks` instead. + - `wp.editor.DocumentTitle` component has been removed. + - `getDocumentTitle` selector (`core/editor`) has been removed. + +## 3.7.0 + + - `wp.components.withAPIData` has been removed. Please use the Core Data module or `wp.apiFetch` directly instead. + - `wp.data.dispatch("core").receiveTerms` has been deprecated. Please use `wp.data.dispatch("core").receiveEntityRecords` instead. + - `getCategories` resolver has been deprecated. Please use `getEntityRecords` resolver instead. + - `wp.data.select("core").getTerms` has been deprecated. Please use `wp.data.select("core").getEntityRecords` instead. + - `wp.data.select("core").getCategories` has been deprecated. Please use `wp.data.select("core").getEntityRecords` instead. + - `wp.data.select("core").isRequestingCategories` has been deprecated. Please use `wp.data.select("core/data").isResolving` instead. + - `wp.data.select("core").isRequestingTerms` has been deprecated. Please use `wp.data.select("core").isResolving` instead. + - `wp.data.restrictPersistence`, `wp.data.setPersistenceStorage` and `wp.data.setupPersistence` has been removed. Please use the data persistence plugin instead. + +## 3.6.0 + + - `wp.editor.editorMediaUpload` has been removed. Please use `wp.editor.mediaUpload` instead. + - `wp.utils.getMimeTypesArray` has been removed. + - `wp.utils.mediaUpload` has been removed. Please use `wp.editor.mediaUpload` instead. + - `wp.utils.preloadImage` has been removed. + - `supports.wideAlign` has been removed from the Block API. Please use `supports.alignWide` instead. + - `wp.blocks.isSharedBlock` has been removed. Use `wp.blocks.isReusableBlock` instead. + - `fetchSharedBlocks` action (`core/editor`) has been removed. Use `fetchReusableBlocks` instead. + - `receiveSharedBlocks` action (`core/editor`) has been removed. Use `receiveReusableBlocks` instead. + - `saveSharedBlock` action (`core/editor`) has been removed. Use `saveReusableBlock` instead. + - `deleteSharedBlock` action (`core/editor`) has been removed. Use `deleteReusableBlock` instead. + - `updateSharedBlockTitle` action (`core/editor`) has been removed. Use `updateReusableBlockTitle` instead. + - `convertBlockToSaved` action (`core/editor`) has been removed. Use `convertBlockToReusable` instead. + - `getSharedBlock` selector (`core/editor`) has been removed. Use `getReusableBlock` instead. + - `isSavingSharedBlock` selector (`core/editor`) has been removed. Use `isSavingReusableBlock` instead. + - `isFetchingSharedBlock` selector (`core/editor`) has been removed. Use `isFetchingReusableBlock` instead. + - `getSharedBlocks` selector (`core/editor`) has been removed. Use `getReusableBlocks` instead. + +## 3.5.0 + + - `wp.components.ifCondition` has been removed. Please use `wp.compose.ifCondition` instead. + - `wp.components.withGlobalEvents` has been removed. Please use `wp.compose.withGlobalEvents` instead. + - `wp.components.withInstanceId` has been removed. Please use `wp.compose.withInstanceId` instead. + - `wp.components.withSafeTimeout` has been removed. Please use `wp.compose.withSafeTimeout` instead. + - `wp.components.withState` has been removed. Please use `wp.compose.withState` instead. + - `wp.element.pure` has been removed. Please use `wp.compose.pure` instead. + - `wp.element.compose` has been removed. Please use `wp.compose.compose` instead. + - `wp.element.createHigherOrderComponent` has been removed. Please use `wp.compose.createHigherOrderComponent` instead. + - `wp.utils.buildTermsTree` has been removed. + - `wp.utils.decodeEntities` has been removed. Please use `wp.htmlEntities.decodeEntities` instead. + - All references to a block's `uid` have been replaced with equivalent props and selectors for `clientId`. + - The `wp.editor.MediaPlaceholder` component `onSelectUrl` prop has been renamed to `onSelectURL`. + - The `wp.editor.UrlInput` component has been renamed to `wp.editor.URLInput`. + - The Text Columns block has been removed. Please use the Columns block instead. + - `InnerBlocks` grouped layout is removed. Use intermediary nested inner blocks instead. See Columns / Column block for reference implementation. + - `RichText` explicit `element` format removed. Please use the compatible `children` format instead. + +## 3.4.0 + + - `focusOnMount` prop in the `Popover` component has been changed from `Boolean`-only to an enum-style property that accepts `"firstElement"`, `"container"`, or `false`. Please convert any `` usage to ``. + - `wp.utils.keycodes` utilities are removed. Please use `wp.keycodes` instead. + - Block `id` prop in `edit` function removed. Please use block `clientId` prop instead. + - `property` source removed. Please use equivalent `text`, `html`, or `attribute` source, or comment attribute instead. + +## 3.3.0 + + - `useOnce: true` has been removed from the Block API. Please use `supports.multiple: false` instead. + - Serializing components using `componentWillMount` lifecycle method. Please use the constructor instead. + - `blocks.Autocomplete.completers` filter removed. Please use `editor.Autocomplete.completers` instead. + - `blocks.BlockEdit` filter removed. Please use `editor.BlockEdit` instead. + - `blocks.BlockListBlock` filter removed. Please use `editor.BlockListBlock` instead. + - `blocks.MediaUpload` filter removed. Please use `editor.MediaUpload` instead. + +## 3.2.0 + + - `wp.data.withRehydratation` has been renamed to `wp.data.withRehydration`. + - The `wp.editor.ImagePlaceholder` component is removed. Please use `wp.editor.MediaPlaceholder` instead. + - `wp.utils.deprecated` function removed. Please use `wp.deprecated` instead. + - `wp.utils.blob` removed. Please use `wp.blob` instead. + - `getInserterItems`: the `allowedBlockTypes` argument was removed and the `parentUID` argument was added. + - `getFrecentInserterItems` selector removed. Please use `getInserterItems` instead. + - `getSupportedBlocks` selector removed. Please use `canInsertBlockType` instead. + +## 3.1.0 + + - All components in `wp.blocks.*` are removed. Please use `wp.editor.*` instead. + - `wp.blocks.withEditorSettings` is removed. Please use the data module to access the editor settings `wp.data.select( "core/editor" ).getEditorSettings()`. + - All DOM utils in `wp.utils.*` are removed. Please use `wp.dom.*` instead. + - `isPrivate: true` has been removed from the Block API. Please use `supports.inserter: false` instead. + - `wp.utils.isExtraSmall` function removed. Please use `wp.viewport` module instead. + - `getEditedPostExcerpt` selector removed (`core/editor`). Use `getEditedPostAttribute( 'excerpt' )` instead. + +## 3.0.0 + + - `wp.blocks.registerCoreBlocks` function removed. Please use `wp.coreBlocks.registerCoreBlocks` instead. + - Raw TinyMCE event handlers for `RichText` have been deprecated. Please use [documented props](/packages/editor/src/components/rich-text/README.md), ancestor event handler, or onSetup access to the internal editor instance event hub instead. + +## 2.8.0 + + - `Original autocompleter interface in wp.components.Autocomplete` updated. Please use `latest autocompleter interface` instead. See [autocomplete](/packages/components/src/autocomplete/README.md) for more info. + - `getInserterItems`: the `allowedBlockTypes` argument is now mandatory. + - `getFrecentInserterItems`: the `allowedBlockTypes` argument is now mandatory. + +## 2.7.0 + + - `wp.element.getWrapperDisplayName` function removed. Please use `wp.element.createHigherOrderComponent` instead. + +## 2.6.0 + + - `wp.blocks.getBlockDefaultClassname` function removed. Please use `wp.blocks.getBlockDefaultClassName` instead. + - `wp.blocks.Editable` component removed. Please use the `wp.blocks.RichText` component instead. + +## 2.5.0 + + - Returning raw HTML from block `save` is unsupported. Please use the `wp.element.RawHTML` component instead. + - `wp.data.query` higher-order component removed. Please use `wp.data.withSelect` instead. + +## 2.4.0 + + - `wp.blocks.BlockDescription` component removed. Please use the `description` block property instead. + - `wp.blocks.InspectorControls.*` components removed. Please use `wp.components.*` components instead. + - `wp.blocks.source.*` matchers removed. Please use the declarative attributes instead. See [block attributes](/docs/designers-developers/developers/block-api/block-attributes.md) for more info. + - `wp.data.select( 'selector', ...args )` removed. Please use `wp.data.select( reducerKey' ).*` instead. + - `wp.blocks.MediaUploadButton` component removed. Please use `wp.blocks.MediaUpload` component instead. diff --git a/gb-src/docs/designers-developers/developers/backward-compatibility/meta-box.md b/gb-src/docs/designers-developers/developers/backward-compatibility/meta-box.md new file mode 100644 index 0000000000000..dc8ce593eb856 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/backward-compatibility/meta-box.md @@ -0,0 +1,80 @@ +# Meta Boxes + +This is a brief document detailing how meta box support works in the block editor. With the superior developer and user experience of blocks, especially once block templates are available, **porting PHP meta boxes to blocks is highly encouraged!** See the [Meta Block tutorial](/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md) for how to store post meta data using blocks. + +### Testing, Converting, and Maintaining Existing Meta Boxes + +Before converting meta boxes to blocks, it may be easier to test if a meta box works with the block editor, and explicitly mark it as such. + +If a meta box *doesn't* work with the block editor, and updating it to work correctly is not an option, the next step is to add the `__block_editor_compatible_meta_box` argument to the meta box declaration: + +```php +add_meta_box( 'my-meta-box', 'My Meta Box', 'my_meta_box_callback', + null, 'normal', 'high', + array( + '__block_editor_compatible_meta_box' => false, + ) +); +``` + +WordPress won't show the meta box but a message saying that it isn't compatible with the block editor, including a link to the Classic Editor plugin. By default, `__block_editor_compatible_meta_box` is `true`. + +After a meta box is converted to a block, it can be declared as existing for backward compatibility: + +```php +add_meta_box( 'my-meta-box', 'My Meta Box', 'my_meta_box_callback', + null, 'normal', 'high', + array( + '__back_compat_meta_box' => true, + ) +); +``` + +When the block editor is used, this meta box will no longer be displayed in the meta box area, as it now only exists for backward compatibility purposes. It will continue to display correctly in the classic editor. + +### Meta Box Data Collection + +On each block editor page load, we register an action that collects the meta box data to determine if an area is empty. The original global state is reset upon collection of meta box data. + +See [`register_and_do_post_meta_boxes`](https://developer.wordpress.org/reference/functions/register_and_do_post_meta_boxes/). + +It will run through the functions and hooks that `post.php` runs to register meta boxes; namely `add_meta_boxes`, `add_meta_boxes_{$post->post_type}`, and `do_meta_boxes`. + +Meta boxes are filtered to strip out any core meta boxes, standard custom taxonomy meta boxes, and any meta boxes that have declared themselves as only existing for backward compatibility purposes. + +Then each location for this particular type of meta box is checked for whether it is active. If it is not empty a value of true is stored, if it is empty a value of false is stored. This meta box location data is then dispatched by the editor Redux store in `INITIALIZE_META_BOX_STATE`. + +Ideally, this could be done at instantiation of the editor and help simplify this flow. However, it is not possible to know the meta box state before `admin_enqueue_scripts`, where we are calling `initializeEditor()`. This will have to do, unless we want to move `initializeEditor()` to fire in the footer or at some point after `admin_head`. With recent changes to editor bootstrapping this might now be possible. Test with ACF to make sure. + +### Redux and React Meta Box Management + +When rendering the block editor, the meta boxes are rendered to a hidden div `#metaboxes`. + +*The Redux store will hold all meta boxes as inactive by default*. When +`INITIALIZE_META_BOX_STATE` comes in, the store will update any active meta box areas by setting the `isActive` flag to `true`. Once this happens React will check for the new props sent in by Redux on the `MetaBox` component. If that `MetaBox` is now active, instead of rendering null, a `MetaBoxArea` component will be rendered. The `MetaBox` component is the container component that mediates between the `MetaBoxArea` and the Redux Store. *If no meta boxes are active, nothing happens. This will be the default behavior, as all core meta boxes have been stripped.* + +#### MetaBoxArea Component + +When the component renders it will store a reference to the meta boxes container and retrieve the meta boxes HTML from the prefetch location. + +When the post is updated, only meta box areas that are active will be submitted. This prevents unnecessary requests. No extra revisions are created by the meta box submissions. A Redux action will trigger on `REQUEST_POST_UPDATE` for any active meta box. See `editor/effects.js`. The `REQUEST_META_BOX_UPDATES` action will set that meta box's state to `isUpdating`. The `isUpdating` prop will be sent into the `MetaBoxArea` and cause a form submission. + +When the meta box area is saving, we display an updating overlay, to prevent users from changing the form values while a save is in progress. + +An example save url would look like: + +`mysite.com/wp-admin/post.php?post=1&action=edit&meta-box-loader=1` + +This url is automatically passed into React via a `_wpMetaBoxUrl` global variable. + +This page mimics the `post.php` post form, so when it is submitted it will fire all of the normal hooks and actions, and have the proper global state to correctly fire any PHP meta box mumbo jumbo without needing to modify any existing code. On successful submission, React will signal a `handleMetaBoxReload` to remove the updating overlay. + +### Common Compatibility Issues + +Most PHP meta boxes should continue to work in the block editor, but some meta boxes that include advanced functionality could break. Here are some common reasons why meta boxes might not work as expected in the block editor: + +- Plugins relying on selectors that target the post title, post content fields, and other metaboxes (of the old editor). +- Plugins relying on TinyMCE's API because there's no longer a single TinyMCE instance to talk to in the block editor. +- Plugins making updates to their DOM on "submit" or on "save". + +Please also note that if your plugin triggers a PHP warning or notice to be output on the page, this will cause the HTML document type (``) to be output incorrectly. This will cause the browser to render using "Quirks Mode", which is a compatibility layer that gets enabled when the browser doesn't know what type of document it is parsing. The block editor is not meant to work in this mode, but it can _appear_ to be working just fine. If you encounter issues such as *meta boxes overlaying the editor* or other layout issues, please check the raw page source of your document to see that the document type definition is the first thing output on the page. There will also be a warning in the JavaScript console, noting the issue. diff --git a/gb-src/docs/designers-developers/developers/block-api/README.md b/gb-src/docs/designers-developers/developers/block-api/README.md new file mode 100644 index 0000000000000..56d159bda6d13 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/README.md @@ -0,0 +1,11 @@ +# Block API Reference + +Blocks are the fundamental element of the editor. They are the primary way in which plugins and themes can register their own functionality and extend the capabilities of the editor. + +## Registering a block + +All blocks must be registered before they can be used in the editor. You can learn about block registration, and the available options, in the [block registration](/docs/designers-developers/developers/block-api/block-registration.md) documentation. + +## Block `edit` and `save` + +The `edit` and `save` functions define the editor interface with which a user would interact, and the markup to be serialized back when a post is saved. They are the heart of how a block operates, so they are [covered separately](/docs/designers-developers/developers/block-api/block-edit-save.md). diff --git a/gb-src/docs/designers-developers/developers/block-api/block-annotations.md b/gb-src/docs/designers-developers/developers/block-api/block-annotations.md new file mode 100644 index 0000000000000..d74a300c54f0b --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-annotations.md @@ -0,0 +1,59 @@ +# Annotations + +**Note: This API is experimental, that means it is subject to non-backward compatible changes or removal in any future version.** + +Annotations are a way to highlight a specific piece in a post created with the block editor. Examples of this include commenting on a piece of text and spellchecking. Both can use the annotations API to mark a piece of text. + +## API + +To see the API for yourself the easiest way is to have a block that is at least 200 characters long without formatting and putting the following in the console: + +```js +wp.data.dispatch( 'core/annotations' ).addAnnotation( { + source: "my-annotations-plugin", + blockClientId: wp.data.select( 'core/editor' ).getBlockOrder()[0], + richTextIdentifier: "content", + range: { + start: 50, + end: 100, + }, +} ); +``` + +The start and the end of the range should be calculated based only on the text of the relevant `RichText`. For example, in the following HTML position 0 will refer to the position before the capital S: + +```html +Strong text +``` + +To help with determining the correct positions, the `wp.richText.create` method can be used. This will split a piece of HTML into text and formats. + +All available properties can be found in the API documentation of the `addAnnotation` action. + +The property `richTextIdentifier` is the identifier of the RichText instance the annotation applies to. This is necessary because blocks may have multiple rich text instances that are used to manage data for different attributes, so you need to pass this in order to highlight text within the correct one. + +For example the Paragraph block only has a single RichText instance, with the identifer `content`. The quote block type has 2 RichText instances, so if you wish to highlight text in the citation, you need to pass `citation` as the `richTextIdentifier` when adding an annotation. To target the quote content, you need to use the identifier `value`. Refer to the source code of the block type to find the correct identifier. + +## Block annotation + +It is also possible to annotate a block completely. In that case just provide the `selector` property and set it to `block`. The default `selector` is `range`, which can be used for text annotation. + +```js +wp.data.dispatch( 'core/annotations' ).addAnnotation( { + source: "my-annotations-plugin", + blockClientId: wp.data.select( 'core/editor' ).getBlockOrder()[0], + selector: "block", +} ); +``` + +This doesn't provide any styling out of the box, so you have to provide some CSS to make sure your annotation is shown: + +```css +.is-annotated-by-my-annotations-plugin { + outline: 1px solid black; +} +``` + +## Text annotation + +The text annotation is controlled by the `start` and `end` properties. Simple `start` and `end` properties don't work for HTML, so these properties are assumed to be offsets within the `rich-text` internal structure. For simplicity you can think about this as if all HTML would be stripped out and then you calculate the `start` and the `end` of the annotation. diff --git a/gb-src/docs/designers-developers/developers/block-api/block-attributes.md b/gb-src/docs/designers-developers/developers/block-api/block-attributes.md new file mode 100644 index 0000000000000..65386bba141c0 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-attributes.md @@ -0,0 +1,222 @@ +# Attributes + +## Common Sources + +Attribute sources are used to define how the block attribute values are extracted from saved post content. They provide a mechanism to map from the saved markup to a JavaScript representation of a block. + +If no attribute source is specified, the attribute will be saved to (and read from) the block's [comment delimiter](/docs/designers-developers/key-concepts.md#delimiters-and-parsing-expression-grammar). + +The keys specified in the attributes source object are named as you see fit. The result of the attribute source definition is assigned as a value to each key. + +If no selector argument is specified, the source definition runs against the block's root node. If a selector argument is specified, it will run against the specified element(s) contained within the block. + +The selector specified can be an HTML tag, or anything queryable such as a class or id attribute, see examples below. + +Under the hood, attribute sources are a superset of the functionality provided by [hpq](https://github.com/aduth/hpq), a small library used to parse and query HTML markup into an object shape. + + +### `attribute` + +Use `attribute` to extract the value of an attribute from markup. + +_Example_: Extract the `src` attribute from an image found in the block's markup. + +```js +{ + url: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'src', + } +} +// { "url": "https://lorempixel.com/1200/800/" } +``` + +#### `attribute` Type Validation + +Accepted values in the `type` field of an `attribute` MUST be one of the following: + +* null +* boolean +* object +* array +* number +* string +* integer + +See [WordPress's REST API documentation](https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/) for additional details. + +### `text` + +Use `text` to extract the inner text from markup. + +```js +{ + content: { + type: 'string', + source: 'text', + selector: 'figcaption', + } +} +// { "content": "The inner text of the figcaption element" } +``` + +Another example, using `text` as the source, and using `.my-content` class as the selector to extract text: + +```js +{ + content: { + type: 'string', + source: 'text', + selector: '.my-content', + } +} +// { "content": "The inner text of .my-content class" } +``` + +### `html` + +Use `html` to extract the inner HTML from markup. + +```js +{ + content: { + type: 'string', + source: 'html', + selector: 'figcaption', + } +} +// { "content": "The inner text of the figcaption element" } +``` + +Use the `multiline` property to extract the inner HTML of matching tag names for the use in `RichText` with the `multiline` prop. + +```js +{ + content: { + type: 'string', + source: 'html', + multiline: 'p', + selector: 'blockquote', + } +} +// { "content": "

First line

Second line

" } +``` + +### `query` + +Use `query` to extract an array of values from markup. Entries of the array are determined by the selector argument, where each matched element within the block will have an entry structured corresponding to the second argument, an object of attribute sources. + +_Example_: Extract `src` and `alt` from each image element in the block's markup. + +```js +{ + images: { + type: 'array', + source: 'query' + selector: 'img', + query: { + url: { + type: 'string', + source: 'attribute', + attribute: 'src', + }, + alt: { + type: 'string', + source: 'attribute', + attribute: 'alt', + }, + } + } +} +// { +// "images": [ +// { "url": "https://lorempixel.com/1200/800/", "alt": "large image" }, +// { "url": "https://lorempixel.com/50/50/", "alt": "small image" } +// ] +// } +``` + +## Meta + +Attributes may be obtained from a post's meta rather than from the block's representation in saved post content. For this, an attribute is required to specify its corresponding meta key under the `meta` key: + +```js +attributes: { + author: { + type: 'string', + source: 'meta', + meta: 'author' + }, +}, +``` + +From here, meta attributes can be read and written by a block using the same interface as any attribute: + +{% codetabs %} +{% ES5 %} +```js +edit: function( props ) { + function onChange( event ) { + props.setAttributes( { author: event.target.value } ); + } + + return el( 'input', { + value: props.attributes.author, + onChange: onChange, + } ); +}, +``` +{% ESNext %} +```js +edit( { attributes, setAttributes } ) { + function onChange( event ) { + setAttributes( { author: event.target.value } ); + } + + return ; +}, +``` +{% end %} + +### Considerations + +By default, a meta field will be excluded from a post object's meta. This can be circumvented by explicitly making the field visible: + +```php +function gutenberg_my_block_init() { + register_post_meta( 'post', 'author', array( + 'show_in_rest' => true, + ) ); +} +add_action( 'init', 'gutenberg_my_block_init' ); +``` + +Furthermore, be aware that WordPress defaults to: + +- not treating a meta datum as being unique, instead returning an array of values; +- treating that datum as a string. + +If either behavior is not desired, the same `register_post_meta` call can be complemented with the `single` and/or `type` parameters as follows: + +```php +function gutenberg_my_block_init() { + register_post_meta( 'post', 'author_count', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'integer', + ) ); +} +add_action( 'init', 'gutenberg_my_block_init' ); +``` + +If you'd like to use an object or an array in an attribute, you can register a `string` attribute type and use JSON as the intermediary. Serialize the structured data to JSON prior to saving, and then deserialize the JSON string on the server. Keep in mind that you're responsible for the integrity of the data; make sure to properly sanitize, accommodate missing data, etc. + +Lastly, make sure that you respect the data's type when setting attributes, as the framework does not automatically perform type casting of meta. Incorrect typing in block attributes will result in a post remaining dirty even after saving (_cf._ `isEditedPostDirty`, `hasEditedAttributes`). For instance, if `authorCount` is an integer, remember that event handlers may pass a different kind of data, thus the value should be cast explicitly: + +```js +function onChange( event ) { + props.setAttributes( { authorCount: Number( event.target.value ) } ); +} +``` diff --git a/gb-src/docs/designers-developers/developers/block-api/block-deprecation.md b/gb-src/docs/designers-developers/developers/block-api/block-deprecation.md new file mode 100644 index 0000000000000..4645b3b5207d6 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-deprecation.md @@ -0,0 +1,280 @@ +# Deprecated Blocks + +When updating static blocks markup and attributes, block authors need to consider existing posts using the old versions of their block. In order to provide a good upgrade path, you can choose one of the following strategies: + + - Do not deprecate the block and create a new one (a different name) + - Provide a "deprecated" version of the block allowing users opening these in the block editor to edit them using the updated block. + +A block can have several deprecated versions. A deprecation will be tried if a parsed block appears to be invalid, or if there is a deprecation defined for which its `isEligible` property function returns true. + +Deprecations are defined on a block type as its `deprecated` property, an array of deprecation objects where each object takes the form: + +- `attributes` (Object): The [attributes definition](/docs/designers-developers/developers/block-api/block-attributes.md) of the deprecated form of the block. +- `support` (Object): The [supports definition](/docs/designers-developers/developers/block-api/block-registration.md) of the deprecated form of the block. +- `save` (Function): The [save implementation](/docs/designers-developers/developers/block-api/block-edit-save.md) of the deprecated form of the block. +- `migrate` (Function, Optional): A function which, given the old attributes and inner blocks is expected to return either the new attributes or a tuple array of `[ attributes, innerBlocks ]` compatible with the block. +- `isEligible` (Function, Optional): A function which, given the attributes and inner blocks of the parsed block, returns true if the deprecation can handle the block migration. This is particularly useful in cases where a block is technically valid even once deprecated, and requires updates to its attributes or inner blocks. + +It's important to note that `attributes`, `support`, and `save` are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration. + +### Example: + +{% codetabs %} +{% ES5 %} +```js +var el = wp.element.createElement, + registerBlockType = wp.blocks.registerBlockType, + attributes = { + text: { + type: 'string', + default: 'some random value', + } + }; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... other block properties go here + + attributes: attributes, + + save: function( props ) { + return el( 'div', {}, props.attributes.text ); + }, + + deprecated: [ + { + attributes: attributes, + + save: function( props ) { + return el( 'p', {}, props.attributes.text ); + }, + } + ] +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const attributes = { + text: { + type: 'string', + default: 'some random value', + } +}; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... other block properties go here + + attributes, + + save( props ) { + return
{ props.attributes.text }
; + }, + + deprecated: [ + { + attributes, + + save( props ) { + return

{ props.attributes.text }

; + }, + } + ] +} ); +``` +{% end %} + +In the example above we updated the markup of the block to use a `div` instead of `p`. + + +## Changing the attributes set + +Sometimes, you need to update the attributes set to rename or modify old attributes. + +### Example: + +{% codetabs %} +{% ES5 %} +```js +var el = wp.element.createElement, + registerBlockType = wp.blocks.registerBlockType; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... other block properties go here + + attributes: { + content: { + type: 'string', + default: 'some random value', + } + }, + + save: function( props ) { + return el( 'div', {}, props.attributes.content ); + }, + + deprecated: [ + { + attributes: { + text: { + type: 'string', + default: 'some random value', + } + }, + + migrate: function( attributes ) { + return { + content: attributes.text + }; + }, + + save: function( props ) { + return el( 'p', {}, props.attributes.text ); + }, + } + ] +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... other block properties go here + + attributes: { + content: { + type: 'string', + default: 'some random value', + } + }, + + save( props ) { + return
{ props.attributes.content }
; + }, + + deprecated: [ + { + attributes: { + text: { + type: 'string', + default: 'some random value', + } + }, + + migrate( { text } ) { + return { + content: text + }; + }, + + save( props ) { + return

{ props.attributes.text }

; + }, + } + ] +} ); +``` +{% end %} + +In the example above we updated the markup of the block to use a `div` instead of `p` and rename the `text` attribute to `content`. + + +## Changing the innerBlocks + +Situations may exist where when migrating the block we may need to add or remove innerBlocks. +E.g: a block wants to migrate a title attribute to a paragraph innerBlock. + +### Example: + +{% codetabs %} +{% ES5 %} +```js +var el = wp.element.createElement, + registerBlockType = wp.blocks.registerBlockType, + omit = lodash.omit; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... block properties go here + + deprecated: [ + { + attributes: { + title: { + type: 'string', + source: 'html', + selector: 'p', + }, + }, + + migrate: function( attributes, innerBlocks ) { + return [ + omit( attributes, 'title' ), + [ + createBlock( 'core/paragraph', { + content: attributes.title, + fontSize: 'large', + } ), + ].concat( innerBlocks ), + ]; + }, + + save: function( props ) { + return el( 'p', {}, props.attributes.title ); + }, + } + ] +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { omit } = lodash; + +registerBlockType( 'gutenberg/block-with-deprecated-version', { + + // ... block properties go here + + save( props ) { + return

{ props.attributes.title }

; + }, + + deprecated: [ + { + attributes: { + title: { + type: 'string', + source: 'html', + selector: 'p', + }, + }, + + migrate( attributes, innerBlocks ) { + return [ + omit( attributes, 'title' ), + [ + createBlock( 'core/paragraph', { + content: attributes.title, + fontSize: 'large', + } ), + ...innerBlocks, + ], + ]; + }, + + save( props ) { + return

{ props.attributes.title }

; + }, + } + ] +} ); +``` +{% end %} + +In the example above we updated the block to use an inner Paragraph block with a title instead of a title attribute. + +*Above are example cases of block deprecation. For more, real-world examples, check for deprecations in the [core block library](/packages/block-library/src/README.md). Core blocks have been updated across releases and contain simple and complex deprecations.* diff --git a/gb-src/docs/designers-developers/developers/block-api/block-edit-save.md b/gb-src/docs/designers-developers/developers/block-api/block-edit-save.md new file mode 100644 index 0000000000000..5d0cc72b6c6b7 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-edit-save.md @@ -0,0 +1,418 @@ +# Edit and Save + +When registering a block, the `edit` and `save` functions provide the interface for how a block is going to be rendered within the editor, how it will operate and be manipulated, and how it will be saved. + +## Edit + +The `edit` function describes the structure of your block in the context of the editor. This represents what the editor will render when the block is used. + +{% codetabs %} +{% ES5 %} +```js +// A static div +edit: function() { + return wp.element.createElement( + 'div', + null, + 'Your block.' + ); +} +``` +{% ESNext %} +```jsx +edit: () => { + return
Your block.
; +} +``` +{% end %} + +The function receives the following properties through an object argument: + +### attributes + +This property surfaces all the available attributes and their corresponding values, as described by the `attributes` property when the block type was registered. See [attributes documentation](/docs/designers-developers/developers/block-api/block-attributes.md) for how to specify attribute sources. + +In this case, assuming we had defined an attribute of `content` during block registration, we would receive and use that value in our edit function: + +{% codetabs %} +{% ES5 %} +```js +edit: function( props ) { + return wp.element.createElement( + 'div', + null, + props.attributes.content + ); +} +``` +{% ESNext %} +```js +edit: ( { attributes } ) => { + return
{ attributes.content }
; +} +``` +{% end %} + +The value of `attributes.content` will be displayed inside the `div` when inserting the block in the editor. + +### className + +This property returns the class name for the wrapper element. This is automatically added in the `save` method, but not on `edit`, as the root element may not correspond to what is _visually_ the main element of the block. You can request it to add it to the correct element in your function. + +{% codetabs %} +{% ES5 %} +```js +edit: function( props ) { + return wp.element.createElement( + 'div', + { className: props.className }, + props.attributes.content + ); +} +``` +{% ESNext %} +```js +edit: ( { attributes, className } ) => { + return
{ attributes.content }
; +} +``` +{% end %} + +### isSelected + +The isSelected property is an object that communicates whether the block is currently selected. + +{% codetabs %} +{% ES5 %} +```js +edit: function( props ) { + return wp.element.createElement( + 'div', + { className: props.className }, + [ + 'Your block.', + props.isSelected ? wp.element.createElement( + 'span', + null, + 'Shows only when the block is selected.' + ) + ] + ); +} +``` +{% ESNext %} +```jsx +edit: ( { attributes, className, isSelected } ) => { + return ( +
+ Your block. + { isSelected && + Shows only when the block is selected. + } +
+ ); +} +``` +{% end %} + +### setAttributes + +This function allows the block to update individual attributes based on user interactions. + +{% codetabs %} +{% ES5 %} +```js +edit: function( props ) { + // Simplify access to attributes + let content = props.attributes.content; + let mySetting = props.attributes.mySetting; + + // Toggle a setting when the user clicks the button + let toggleSetting = () => props.setAttributes( { mySetting: ! mySetting } ); + return wp.element.createElement( + 'div', + { className: props.className }, + [ + content, + props.isSelected ? wp.element.createElement( + 'button', + { onClick: toggleSetting }, + 'Toggle setting' + ) : null + ] + ); +}, +``` +{% ESNext %} +```jsx +edit: ( { attributes, setAttributes, className, isSelected } ) => { + // Simplify access to attributes + const { content, mySetting } = attributes; + + // Toggle a setting when the user clicks the button + const toggleSetting = () => setAttributes( { mySetting: ! mySetting } ); + return ( +
+ { content } + { isSelected && + + } +
+ ); +} +``` +{% end %} + +When using attributes that are objects or arrays it's a good idea to copy or clone the attribute prior to updating it: + +{% codetabs %} +{% ES5 %} +```js +// Good - cloning the old list +var newList = attributes.list.slice(); + +var addListItem = function( newListItem ) { + setAttributes( { list: newList.concat( [ newListItem ] ) } ); +}; + +// Bad - the list from the existing attribute is modified directly to add the new list item: +var list = attributes.list; +var addListItem = function( newListItem ) { + list.push( newListItem ); + setAttributes( { list: list } ); +}; +``` +{% ESNext %} +```js +// Good - a new array is created from the old list attribute and a new list item: +const { list } = attributes; +const addListItem = ( newListItem ) => setAttributes( { list: [ ...list, newListItem ] } ); + +// Bad - the list from the existing attribute is modified directly to add the new list item: +const { list } = attributes; +const addListItem = ( newListItem ) => { + list.push( newListItem ); + setAttributes( { list } ); +}; +``` +{% end %} + +Why do this? In JavaScript, arrays and objects are passed by reference, so this practice ensures changes won't affect other code that might hold references to the same data. Furthermore, the Gutenberg project follows the philosophy of the Redux library that [state should be immutable](https://redux.js.org/faq/immutable-data#what-are-the-benefits-of-immutability)—data should not be changed directly, but instead a new version of the data created containing the changes. + +## Save + +The `save` function defines the way in which the different attributes should be combined into the final markup, which is then serialized into `post_content`. + +{% codetabs %} +{% ES5 %} +```js +save: function() { + return wp.element.createElement( + 'div', + null, + 'Your block.' + ); +} +``` +{% ESNext %} +```jsx +save: () => { + return
Your block.
; +} +``` +{% end %} + +For most blocks, the return value of `save` should be an [instance of WordPress Element](/packages/element/README.md) representing how the block is to appear on the front of the site. + +_Note:_ While it is possible to return a string value from `save`, it _will be escaped_. If the string includes HTML markup, the markup will be shown on the front of the site verbatim, not as the equivalent HTML node content. If you must return raw HTML from `save`, use `wp.element.RawHTML`. As the name implies, this is prone to [cross-site scripting](https://en.wikipedia.org/wiki/Cross-site_scripting) and therefore is discouraged in favor of a WordPress Element hierarchy whenever possible. + +_Note:_ The save function should be a pure function that depends only on the attributes used to invoke it. +It can not have any side effect or retrieve information from another source, e.g. it is not possible to use the data module inside it `select( store ).selector( ... )`. +This is because if the external information changes, the block may be flagged as invalid when the post is later edited ([read more about Validation](#validation)). +If there is a need to have other information as part of the save, developers can consider one of these two alternatives: + - Use [dynamic blocks](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md) and dynamically retrieve the required information on the server. + - Store the external value as an attribute which is dynamically updated in the block's `edit` function as changes occur. + +For [dynamic blocks](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md), the return value of `save` could represent a cached copy of the block's content to be shown only in case the plugin implementing the block is ever disabled. + +If left unspecified, the default implementation will save no markup in post content for the dynamic block, instead deferring this to always be calculated when the block is shown on the front of the site. + +### attributes + +As with `edit`, the `save` function also receives an object argument including attributes which can be inserted into the markup. + +{% codetabs %} +{% ES5 %} +```js +save: function( props ) { + return wp.element.createElement( + 'div', + null, + props.attributes.content + ); +} +``` +{% ESNext %} +```jsx +save: ( { attributes } ) => { + return
{ attributes.content }
; +} +``` +{% end %} + + +When saving your block, you want to save the attributes in the same format specified by the attribute source definition. If no attribute source is specified, the attribute will be saved to the block's comment delimiter. See the [Block Attributes documentation](/docs/designers-developers/developers/block-api/block-attributes.md) for more details. + +## Examples + +Here are a couple examples of using attributes, edit, and save all together. For a full working example, see the [Introducing Attributes and Editable Fields](/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md) section of the Block Tutorial. + +### Saving Attributes to Child Elements + +{% codetabs %} +{% ES5 %} +```js +attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p' + } +}, + +edit: function( props ) { + var updateFieldValue = function( val ) { + props.setAttributes( { content: val } ); + } + return wp.element.createElement( + wp.components.TextControl, + { + label: 'My Text Field', + value: props.attributes.content, + onChange: updateFieldValue, + + } + ); +}, + +save: function( props ) { + return el( 'p', {}, props.attributes.content ); +}, +``` +{% ESNext %} +```jsx +attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p' + } +}, + +edit: ( { attributes, setAttributes } ) => { + const updateFieldValue = ( val ) => { + setAttributes( { content: val } ); + } + return ; +}, + +save: ( { attributes } ) => { + return

{ attributes.content }

; +}, +``` +{% end %} + +### Saving Attributes via Serialization + +Ideally, the attributes saved should be included in the markup. However, there are times when this is not practical, so if no attribute source is specified the attribute is serialized and saved to the block's comment delimiter. + +This example could be for a dynamic block, such as the [Latest Posts block](https://github.com/WordPress/gutenberg/blob/master/packages/block-library/src/latest-posts/index.js), which renders the markup server-side. The save function is still required, however in this case it simply returns null since the block is not saving content from the editor. + +{% codetabs %} +{% ES5 %} +```js +attributes: { + postsToShow: { + type: 'number', + } +}, + +edit: function( props ) { + return wp.element.createElement( + wp.components.TextControl, + { + label: 'Number Posts to Show', + value: props.attributes.postsToShow, + onChange: function( val ) { + props.setAttributes( { postsToShow: parseInt( val ) } ); + }, + } + ); +}, + +save: function() { + return null; +} +``` +{% ESNext %} +```jsx +attributes: { + postsToShow: { + type: 'number', + } +}, + +edit: ( { attributes, setAttributes } ) => { + return { + setAttributes( { postsToShow: parseInt( val ) } ); + }}, + } + ); +}, + +save: () => { + return null; +} +``` +{% end %} + + +## Validation + +When the editor loads, all blocks within post content are validated to determine their accuracy in order to protect against content loss. This is closely related to the saving implementation of a block, as a user may unintentionally remove or modify their content if the editor is unable to restore a block correctly. During editor initialization, the saved markup for each block is regenerated using the attributes that were parsed from the post's content. If the newly-generated markup does not match what was already stored in post content, the block is marked as invalid. This is because we assume that unless the user makes edits, the markup should remain identical to the saved content. + +If a block is detected to be invalid, the user will be prompted to choose how to handle the invalidation: + +![Invalid block prompt](https://user-images.githubusercontent.com/1779930/35637234-a6a7a18a-0681-11e8-858b-adfc1c6f47da.png) + +- **Overwrite**: Ignores the warning and treats the newly generated markup as correct. As noted in the behavior described above, this can result in content loss since it will overwrite the markup saved in post content. +- **Convert to Classic**: Protects the original markup from the saved post content as correct. Since the block will be converted from its original type to the Classic block type, it will no longer be possible to edit the content using controls available for the original block type. +- **Edit as HTML block**: Similar to _Convert to Classic_, this will protect the original markup from the saved post content and convert the block from its original type to the HTML block type, enabling the user to modify the HTML markup directly. + +### Validation FAQ + +**How do blocks become invalid?** + +The two most common sources of block invalidations are: + +1. A flaw in a block's code would result in unintended content modifications. See the question below on how to debug block invalidation as a plugin author. +2. You or an external editor changed the HTML markup of the block in such a way that it is no longer considered correct. + +**I'm a plugin author. What should I do to debug why my blocks are being marked as invalid?** + +Before starting to debug, be sure to familiarize yourself with the validation step described above documenting the process for detecting whether a block is invalid. A block is invalid if its regenerated markup does not match what is saved in post content, so often this can be caused by the attributes of a block being parsed incorrectly from the saved content. + +If you're using [attribute sources](/docs/designers-developers/developers/block-api/block-attributes.md), be sure that attributes sourced from markup are saved exactly as you expect, and in the correct type (usually a `'string'` or `'number'`). + +When a block is detected as invalid, a warning will be logged into your browser's developer tools console. The warning will include specific details about the exact point at which a difference in markup occurred. Be sure to look closely at any differences in the expected and actual markups to see where problems are occurring. + +**I've changed my block's `save` behavior and old content now includes invalid blocks. How can I fix this?** + +Refer to the guide on [Deprecated Blocks](/docs/designers-developers/developers/block-api/block-deprecation.md) to learn more about how to accommodate legacy content in intentional markup changes. diff --git a/gb-src/docs/designers-developers/developers/block-api/block-registration.md b/gb-src/docs/designers-developers/developers/block-api/block-registration.md new file mode 100644 index 0000000000000..2aff571710118 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-registration.md @@ -0,0 +1,618 @@ +# Block Registration + +## `register_block_type` + +* **Type:** `Function` + +Every block starts by registering a new block type definition. To register, you use the `registerBlockType` function from the [`wp-blocks` package](/packages/blocks/README.md#registerBlockType). The function takes two arguments, a block `name` and a block configuration object. + +### Block Name + +* **Type:** `String` + +The name for a block is a unique string that identifies a block. Names have to be structured as `namespace/block-name`, where namespace is the name of your plugin or theme. + +```js +// Registering my block with a unique name +registerBlockType( 'my-plugin/book', {} ); +``` + +*Note:* A block name can only contain lowercase alphanumeric characters and dashes, and must begin with a letter. + +*Note:* This name is used on the comment delimiters as ``. Those blocks provided by core don't include a namespace when serialized. + +### Block Configuration + +* **Type:** `Object` [ `{ key: value }` ] + +A block requires a few properties to be specified before it can be registered successfully. These are defined through a configuration object, which includes the following: + +#### Title + +* **Type:** `String` + +This is the display title for your block, which can be translated with our translation functions. The block inserter will show this name. + +```js +// Our data object +title: __( 'Book' ) +``` + +#### Description (optional) + +* **Type:** `String` + +This is a short description for your block, which can be translated with our translation functions. This will be shown in the Block Tab in the Settings Sidebar. + +```js +description: __( 'Block showing a Book card.' ) +``` + +#### Category + +* **Type:** `String` [ common | formatting | layout | widgets | embed ] + +Blocks are grouped into categories to help users browse and discover them. + +The core provided categories are: + +* common +* formatting +* layout +* widgets +* embed + +```js +// Assigning to the 'widgets' category +category: 'widgets', +``` + +Plugins and Themes can also register [custom block categories](/docs/designers-developers/developers/filters/block-filters.md#managing-block-categories). + +#### Icon (optional) + +* **Type:** `String` | `Object` + +An icon property should be specified to make it easier to identify a block. These can be any of [WordPress' Dashicons](https://developer.wordpress.org/resource/dashicons/), or a custom `svg` element. + +```js +// Specifying a dashicon for the block +icon: 'book-alt', + +// Specifying a custom svg for the block +icon: , +``` + +**Note:** Custom SVG icons are automatically wrapped in the [`wp.components.SVG` component](/packages/components/src/primitives/svg/) to add accessibility attributes (`aria-hidden`, `role`, and `focusable`). + +An object can also be passed as icon, in this case, icon, as specified above, should be included in the src property. + +Besides src the object can contain background and foreground colors, this colors will appear with the icon when they are applicable e.g.: in the inserter. + +```js +icon: { + // Specifying a background color to appear with the icon e.g.: in the inserter. + background: '#7e70af', + // Specifying a color for the icon (optional: if not set, a readable color will be automatically defined) + foreground: '#fff', + // Specifying a dashicon for the block + src: 'book-alt', +} , +``` + +#### Keywords (optional) + +* **Type:** `Array` + +Sometimes a block could have aliases that help users discover it while searching. For example, an `image` block could also want to be discovered by `photo`. You can do so by providing an array of terms (which can be translated). + +```js +// Make it easier to discover a block with keyword aliases. +// These can be localised so your keywords work across locales. +keywords: [ __( 'image' ), __( 'photo' ), __( 'pics' ) ], +``` + +#### Styles (optional) + +* **Type:** `Array` + +Block styles can be used to provide alternative styles to block. It works by adding a class name to the block’s wrapper. Using CSS, a theme developer can target the class name for the style variation if it is selected. + +```js +// Register block styles. +styles: [ + // Mark style as default. + { + name: 'default', + label: __( 'Rounded' ), + isDefault: true + }, + { + name: 'outline', + label: __( 'Outline' ) + }, + { + name: 'squared', + label: __( 'Squared' ) + }, +], +``` + +Plugins and Themes can also register [custom block style](/docs/designers-developers/developers/filters/block-filters.md#block-style-variations) for existing blocks. + +#### Attributes (optional) + +* **Type:** `Object` + +Attributes provide the structured data needs of a block. They can exist in different forms when they are serialized, but they are declared together under a common interface. + +```js +// Specifying my block attributes +attributes: { + cover: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + author: { + type: 'string', + source: 'html', + selector: '.book-author', + }, + pages: { + type: 'number', + }, +}, +``` + +* **See: [Attributes](/docs/designers-developers/developers/block-api/block-attributes.md).** + +#### Transforms (optional) + +* **Type:** `Array` + +Transforms provide rules for what a block can be transformed from and what it can be transformed to. A block can be transformed from another block, a shortcode, a regular expression, a file or a raw DOM node. + +For example, a Paragraph block can be transformed into a Heading block. This uses the `createBlock` function from the [`wp-blocks` package](/packages/blocks/README.md#createBlock). + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: function ( attributes ) { + return createBlock( 'core/heading', { + content: attributes.content, + } ); + }, + }, + ] +}, +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { content } ) => { + return createBlock( 'core/heading', { + content, + } ); + }, + }, + ] +}, +``` +{% end %} + +An existing shortcode can be transformed into its block counterpart. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'shortcode', + // Shortcode tag can also be an array of shortcode aliases + tag: 'caption', + attributes: { + // An attribute can be source from a tag attribute in the shortcode content + url: { + type: 'string', + source: 'attribute', + attribute: 'src', + selector: 'img', + }, + // An attribute can be source from the shortcode attributes + align: { + type: 'string', + shortcode: function( attributes ) { + var align = attributes.named.align ? attributes.named.align : 'alignnone'; + return align.replace( 'align', '' ); + }, + }, + }, + }, + ] +}, +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'shortcode', + // Shortcode tag can also be an array of shortcode aliases + tag: 'caption', + attributes: { + // An attribute can be source from a tag attribute in the shortcode content + url: { + type: 'string', + source: 'attribute', + attribute: 'src', + selector: 'img', + }, + // An attribute can be source from the shortcode attributes + align: { + type: 'string', + shortcode: ( { named: { align = 'alignnone' } } ) => { + return align.replace( 'align', '' ); + }, + }, + }, + }, + ] +}, + +``` +{% end %} + +A block can also be transformed into another block type. For example, a Heading block can be transformed into a Paragraph block. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: function( attributes ) { + return createBlock( 'core/paragraph', { + content: attributes.content, + } ); + }, + }, + ], +}, +``` +{% ESNext %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { content } ) => { + return createBlock( 'core/paragraph', { + content, + } ); + }, + }, + ], +}, +``` +{% end %} + +In addition to accepting an array of known block types, the `blocks` option also accepts a "wildcard" (`"*"`). This allows for transformations which apply to _all_ block types (eg: all blocks can transform into `core/group`): + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ '*' ], // wildcard - match any block + transform: function( attributes, innerBlocks ) { + // transform logic here + }, + }, + ], +}, +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ '*' ], // wildcard - match any block + transform: ( attributes, innerBlocks ) => { + // transform logic here + }, + }, + ], +}, +``` +{% end %} + + +A block with InnerBlocks can also be transformed from and to another block with InnerBlocks. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'some/block-with-innerblocks' ], + transform: function( attributes, innerBlocks ) { + return createBlock( 'some/other-block-with-innerblocks', attributes, innerBlocks ); + }, + }, + ], +}, +``` +{% ESNext %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'some/block-with-innerblocks' ], + transform: ( attributes, innerBlocks ) => { + return createBlock( 'some/other-block-with-innerblocks', attributes, innerBlocks); + }, + }, + ], +}, +``` +{% end %} + +An optional `isMatch` function can be specified on a transform object. This provides an opportunity to perform additional checks on whether a transform should be possible. Returning `false` from this function will prevent the transform from being displayed as an option to the user. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + isMatch: function( attributes ) { + return attributes.isText; + }, + transform: function( attributes ) { + return createBlock( 'core/paragraph', { + content: attributes.content, + } ); + }, + }, + ], +}, +``` +{% ESNext %} +```js +transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + isMatch: ( { isText } ) => isText, + transform: ( { content } ) => { + return createBlock( 'core/paragraph', { + content, + } ); + }, + }, + ], +}, +``` +{% end %} + +To control the priority with which a transform is applied, define a `priority` numeric property on your transform object, where a lower value will take precedence over higher values. This behaves much like a [WordPress hook](https://codex.wordpress.org/Plugin_API#Hook_to_WordPress). Like hooks, the default priority is `10` when not otherwise set. + +A file can be dropped into the editor and converted into a block with a matching transform. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'files', + isMatch: function ( files ) { + return files.length === 1; + }, + // We define a lower priority (higher number) than the default of 10. This + // ensures that the File block is only created as a fallback. + priority: 15, + transform: function( files ) { + var file = files[ 0 ]; + var blobURL = createBlobURL( file ); + + // File will be uploaded in componentDidMount() + return createBlock( 'core/file', { + href: blobURL, + fileName: file.name, + textLinkHref: blobURL, + } ); + }, + }, + ] +} +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'files', + isMatch: ( files ) => files.length === 1, + // We define a lower priority (higher number) than the default of 10. This + // ensures that the File block is only created as a fallback. + priority: 15, + transform: ( files ) => { + const file = files[ 0 ]; + const blobURL = createBlobURL( file ); + + // File will be uploaded in componentDidMount() + return createBlock( 'core/file', { + href: blobURL, + fileName: file.name, + textLinkHref: blobURL, + } ); + }, + }, + ] +} +``` +{% end %} + +A prefix transform is a transform that will be applied if the user prefixes some text in e.g. the Paragraph block with a given pattern and a trailing space. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'prefix', + prefix: '?', + transform: function( content ) { + return createBlock( 'my-plugin/question', { + content, + } ); + }, + }, + ] +} +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'prefix', + prefix: '?', + transform( content ) { + return createBlock( 'my-plugin/question', { + content, + } ); + }, + }, + ] +} +``` +{% end %} + + +#### parent (optional) + +* **Type:** `Array` + +Blocks are able to be inserted into blocks that use [`InnerBlocks`](https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inner-blocks/README.md) as nested content. Sometimes it is useful to restrict a block so that it is only available as a nested block. For example, you might want to allow an 'Add to Cart' block to only be available within a 'Product' block. + +Setting `parent` lets a block require that it is only available when nested within the specified blocks. + +```js +// Only allow this block when it is nested in a Columns block +parent: [ 'core/columns' ], +``` + +#### supports (optional) + +*Some [block supports](#supports-optional) — for example, `anchor` or `className` — apply their attributes by adding additional props on the element returned by `save`. This will work automatically for default HTML tag elements (`div`, etc). However, if the return value of your `save` is a custom component element, you will need to ensure that your custom component handles these props in order for the attributes to be persisted.* + +* **Type:** `Object` + +Optional block extended support features. The following options are supported: + +- `align` (default `false`): This property adds block controls which allow to change block's alignment. _Important: It doesn't work with dynamic blocks yet._ + +```js +// Add the support for block's alignment (left, center, right, wide, full). +align: true, +// Pick which alignment options to display. +align: [ 'left', 'right', 'full' ], +``` +When supports align is used the block attributes definition is extended to include an align attribute with a string type. +By default, no alignment is assigned to the block. +The block can apply a default alignment by specifying its own align attribute with a default e.g.: +``` +attributes: { + ... + align: { + type: 'string', + default: 'right' + }, + ... +} +``` + +- `alignWide` (default `true`): This property allows to enable [wide alignment](/docs/designers-developers/developers/themes/theme-support.md#wide-alignment) for your theme. To disable this behavior for a single block, set this flag to `false`. + +```js +// Remove the support for wide alignment. +alignWide: false, +``` + +- `anchor` (default `false`): Anchors let you link directly to a specific block on a page. This property adds a field to define an id for the block and a button to copy the direct link. + +```js +// Add the support for an anchor link. +anchor: true, +``` + +- `customClassName` (default `true`): This property adds a field to define a custom className for the block's wrapper. + +```js +// Remove the support for the custom className. +customClassName: false, +``` + +- `className` (default `true`): By default, the class `.wp-block-your-block-name` is added to the root element of your saved markup. This helps having a consistent mechanism for styling blocks that themes and plugins can rely on. If for whatever reason a class is not desired on the markup, this functionality can be disabled. + +```js +// Remove the support for the generated className. +className: false, +``` + +- `html` (default `true`): By default, a block's markup can be edited individually. To disable this behavior, set `html` to `false`. + +```js +// Remove support for an HTML mode. +html: false, +``` + +- `inserter` (default `true`): By default, all blocks will appear in the inserter. To hide a block so that it can only be inserted programmatically, set `inserter` to `false`. + +```js +// Hide this block from the inserter. +inserter: false, +``` + +- `multiple` (default `true`): A non-multiple block can be inserted into each post, one time only. For example, the built-in 'More' block cannot be inserted again if it already exists in the post being edited. A non-multiple block's icon is automatically dimmed (unclickable) to prevent multiple instances. + +```js +// Use the block just once per post +multiple: false, +``` + +- `reusable` (default `true`): A block may want to disable the ability of being converted into a reusable block. +By default all blocks can be converted to a reusable block. If supports reusable is set to false, the option to convert the block into a reusable block will not appear. + +```js +// Don't allow the block to be converted into a reusable block. +reusable: false, +``` + diff --git a/gb-src/docs/designers-developers/developers/block-api/block-templates.md b/gb-src/docs/designers-developers/developers/block-api/block-templates.md new file mode 100644 index 0000000000000..b2b384a96e211 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/block-api/block-templates.md @@ -0,0 +1,133 @@ +# Templates + +A block template is defined as a list of block items. Such blocks can have predefined attributes, placeholder content, and be static or dynamic. Block templates allow to specify a default initial state for an editor session. + +The scope of templates include: + +- Setting a default state dynamically on the client. (like `defaultBlock`) +- Registered as a default for a given post type. + +Planned additions: + +- Saved and assigned to pages as "page templates". +- Defined in a `template.php` file or pulled from a custom post type (`wp_templates`) that is site specific. +- As the equivalent of the theme hierarchy. + +## API + +Templates can be declared in JS or in PHP as an array of blockTypes (block name and optional attributes). + +The first example in PHP creates a template for posts that includes an image block to start, you can add as many or as few blocks to your template as needed. + +PHP example: + +```php +template = array( + array( 'core/image' ), + ); +} +add_action( 'init', 'myplugin_register_template' ); +``` + +The following example in JavaScript creates a new block using [InnerBlocks](/packages/block-editor/src/components/inner-blocks/README.md) and templates, when inserted creates a set of blocks based off the template. + +```js +const el = wp.element.createElement; +const { registerBlockType } = wp.blocks; +const { InnerBlocks } = wp.editor; + +const BLOCKS_TEMPLATE = [ + [ 'core/image', {} ], + [ 'core/paragraph', { placeholder: 'Image Details' } ], +]; + +registerBlockType( 'myplugin/template', { + title: 'My Template Block', + category: 'widgets', + edit: ( props ) => { + return el( InnerBlocks, { + template: BLOCKS_TEMPLATE, + templateLock: false + }); + }, + save: ( props ) => { + return el( InnerBlocks.Content, {} ); + }, +}); +``` + +See the [Meta Block Tutorial](/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md) for a full example of a template in use. + +## Custom Post types + +A custom post type can register its own template during registration: + +```php +function myplugin_register_book_post_type() { + $args = array( + 'public' => true, + 'label' => 'Books', + 'show_in_rest' => true, + 'template' => array( + array( 'core/image', array( + 'align' => 'left', + ) ), + array( 'core/heading', array( + 'placeholder' => 'Add Author...', + ) ), + array( 'core/paragraph', array( + 'placeholder' => 'Add Description...', + ) ), + ), + ); + register_post_type( 'book', $args ); +} +add_action( 'init', 'myplugin_register_book_post_type' ); +``` + +### Locking + +Sometimes the intention might be to lock the template on the UI so that the blocks presented cannot be manipulated. This is achieved with a `template_lock` property. + +```php +function myplugin_register_template() { + $post_type_object = get_post_type_object( 'post' ); + $post_type_object->template = array( + array( 'core/paragraph', array( + 'placeholder' => 'Add Description...', + ) ), + ); + $post_type_object->template_lock = 'all'; +} +add_action( 'init', 'myplugin_register_template' ); +``` + +*Options:* + +- `all` — prevents all operations. It is not possible to insert new blocks, move existing blocks, or delete blocks. +- `insert` — prevents inserting or removing blocks, but allows moving existing blocks. + +## Nested Templates + +Container blocks like the columns blocks also support templates. This is achieved by assigning a nested template to the block. + +```php +$template = array( + array( 'core/paragraph', array( + 'placeholder' => 'Add a root-level paragraph', + ) ), + array( 'core/columns', array(), array( + array( 'core/column', array(), array( + array( 'core/image', array() ), + ) ), + array( 'core/column', array(), array( + array( 'core/paragraph', array( + 'placeholder' => 'Add a inner paragraph' + ) ), + ) ), + ) ) +); +``` diff --git a/gb-src/docs/designers-developers/developers/data/README.md b/gb-src/docs/designers-developers/developers/data/README.md new file mode 100644 index 0000000000000..7408d171144cf --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/README.md @@ -0,0 +1,11 @@ +# Data Module Reference + + - [**core**: WordPress Core Data](/docs/designers-developers/developers/data/data-core.md) + - [**core/annotations**: Annotations](/docs/designers-developers/developers/data/data-core-annotations.md) + - [**core/blocks**: Block Types Data](/docs/designers-developers/developers/data/data-core-blocks.md) + - [**core/block-editor**: The Block Editor’s Data](/docs/designers-developers/developers/data/data-core-block-editor.md) + - [**core/editor**: The Post Editor’s Data](/docs/designers-developers/developers/data/data-core-editor.md) + - [**core/edit-post**: The Editor’s UI Data](/docs/designers-developers/developers/data/data-core-edit-post.md) + - [**core/notices**: Notices Data](/docs/designers-developers/developers/data/data-core-notices.md) + - [**core/nux**: The NUX (New User Experience) Data](/docs/designers-developers/developers/data/data-core-nux.md) + - [**core/viewport**: The Viewport Data](/docs/designers-developers/developers/data/data-core-viewport.md) \ No newline at end of file diff --git a/gb-src/docs/designers-developers/developers/data/data-core-annotations.md b/gb-src/docs/designers-developers/developers/data/data-core-annotations.md new file mode 100644 index 0000000000000..e71aefd27d212 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-annotations.md @@ -0,0 +1,20 @@ +# Annotations + +Namespace: `core/annotations`. + +## Selectors + + + +Nothing to document. + + + + +## Actions + + + +Nothing to document. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-block-editor.md b/gb-src/docs/designers-developers/developers/data/data-core-block-editor.md new file mode 100644 index 0000000000000..f6f80cdfec535 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-block-editor.md @@ -0,0 +1,1246 @@ +# The Block Editor’s Data + +Namespace: `core/block-editor`. + +## Selectors + + + +# **canInsertBlockType** + +Determines if the given block type is allowed to be inserted into the block list. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _blockName_ `string`: The name of the block type, e.g.' core/paragraph'. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `boolean`: Whether the given block type is allowed to be inserted. + +# **didAutomaticChange** + +Returns true if the last change was an automatic change, false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the last change was automatic. + +# **getAdjacentBlockClientId** + +Returns the client ID of the block adjacent one at the given reference +startClientId and modifier directionality. Defaults start startClientId to +the selected block, and direction as next block. Returns null if there is no +adjacent block. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _startClientId_ `?string`: Optional client ID of block from which to search. +- _modifier_ `?number`: Directionality multiplier (1 next, -1 previous). + +_Returns_ + +- `?string`: Return the client ID of the block, or null if none exists. + +# **getBlock** + +Returns a block given its client ID. This is a parsed copy of the block, +containing its `blockName`, `clientId`, and current `attributes` state. This +is not the block's registration settings, which must be retrieved from the +blocks module registration store. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `Object`: Parsed block object. + +# **getBlockAttributes** + +Returns a block's attributes given its client ID, or null if no block exists with +the client ID. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `?Object`: Block attributes. + +# **getBlockCount** + +Returns the number of blocks currently present in the post. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `number`: Number of blocks in the post. + +# **getBlockHierarchyRootClientId** + +Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block from which to find root client ID. + +_Returns_ + +- `string`: Root client ID + +# **getBlockIndex** + +Returns the index at which the block corresponding to the specified client +ID occurs within the block order, or `-1` if the block does not exist. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `number`: Index at which block exists in order. + +# **getBlockInsertionPoint** + +Returns the insertion point, the index at which the new inserted block would +be placed. Defaults to the last index. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Object`: Insertion point object with `rootClientId`, `index`. + +# **getBlockListSettings** + +Returns the Block List settings of a block, if any exist. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `?string`: Block client ID. + +_Returns_ + +- `?Object`: Block settings of the block if set. + +# **getBlockMode** + +Returns the block's editing mode, defaulting to "visual" if not explicitly +assigned. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `Object`: Block editing mode. + +# **getBlockName** + +Returns a block's name given its client ID, or null if no block exists with +the client ID. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `string`: Block name. + +# **getBlockOrder** + +Returns an array containing all block client IDs in the editor in the order +they appear. Optionally accepts a root client ID of the block list for which +the order should be returned, defaulting to the top-level block order. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `Array`: Ordered client IDs of editor blocks. + +# **getBlockRootClientId** + +Given a block client ID, returns the root block from which the block is +nested, an empty string for top-level blocks, or null if the block does not +exist. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block from which to find root client ID. + +_Returns_ + +- `?string`: Root client ID, if exists + +# **getBlocks** + +Returns all block objects for the current post being edited as an array in +the order they appear in the post. + +Note: It's important to memoize this selector to avoid return a new instance +on each call + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `Array`: Post blocks. + +# **getBlocksByClientId** + +Given an array of block client IDs, returns the corresponding array of block +objects. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientIds_ `Array`: Client IDs for which blocks are to be returned. + +_Returns_ + +- `Array`: Block objects. + +# **getBlockSelectionEnd** + +Returns the current block selection end. This value may be null, and it +may represent either a singular block selection or multi-selection end. +A selection is singular if its start and end match. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: Client ID of block selection end. + +# **getBlockSelectionStart** + +Returns the current block selection start. This value may be null, and it +may represent either a singular block selection or multi-selection start. +A selection is singular if its start and end match. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: Client ID of block selection start. + +# **getClientIdsOfDescendants** + +Returns an array containing the clientIds of all descendants +of the blocks given. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _clientIds_ `Array`: Array of blocks to inspect. + +_Returns_ + +- `Array`: ids of descendants. + +# **getClientIdsWithDescendants** + +Returns an array containing the clientIds of the top-level blocks +and their descendants of any depth (for nested blocks). + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Array`: ids of top-level and descendant blocks. + +# **getFirstMultiSelectedBlockClientId** + +Returns the client ID of the first block in the multi-selection set, or null +if there is no multi-selection. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: First block client ID in the multi-selection set. + +# **getGlobalBlockCount** + +Returns the total number of blocks, or the total number of blocks with a specific name in a post. +The number returned includes nested blocks. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _blockName_ `?string`: Optional block name, if specified only blocks of that type will be counted. + +_Returns_ + +- `number`: Number of blocks in the post, or number of blocks with name equal to blockName. + +# **getInserterItems** + +Determines the items that appear in the inserter. Includes both static +items (e.g. a regular block type) and dynamic items (e.g. a reusable block). + +Each item object contains what's necessary to display a button in the +inserter and handle its selection. + +The 'utility' property indicates how useful we think an item will be to the +user. There are 4 levels of utility: + +1. Blocks that are contextually useful (utility = 3) +2. Blocks that have been previously inserted (utility = 2) +3. Blocks that are in the common category (utility = 1) +4. All other blocks (utility = 0) + +The 'frecency' property is a heuristic () +that combines block usage frequenty and recency. + +Items are returned ordered descendingly by their 'utility' and 'frecency'. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `Array`: Items that appear in inserter. + +# **getLastMultiSelectedBlockClientId** + +Returns the client ID of the last block in the multi-selection set, or null +if there is no multi-selection. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: Last block client ID in the multi-selection set. + +# **getMultiSelectedBlockClientIds** + +Returns the current multi-selection set of block client IDs, or an empty +array if there is no multi-selection. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Array`: Multi-selected block client IDs. + +# **getMultiSelectedBlocks** + +Returns the current multi-selection set of blocks, or an empty array if +there is no multi-selection. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Array`: Multi-selected block objects. + +# **getMultiSelectedBlocksEndClientId** + +Returns the client ID of the block which ends the multi-selection set, or +null if there is no multi-selection. + +This is not necessarily the last client ID in the selection. + +_Related_ + +- getLastMultiSelectedBlockClientId + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: Client ID of block ending multi-selection. + +# **getMultiSelectedBlocksStartClientId** + +Returns the client ID of the block which begins the multi-selection set, or +null if there is no multi-selection. + +This is not necessarily the first client ID in the selection. + +_Related_ + +- getFirstMultiSelectedBlockClientId + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: Client ID of block beginning multi-selection. + +# **getNextBlockClientId** + +Returns the next block's client ID from the given reference start ID. +Defaults start to the selected block. Returns null if there is no next +block. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _startClientId_ `?string`: Optional client ID of block from which to search. + +_Returns_ + +- `?string`: Adjacent block's client ID, or null if none exists. + +# **getPreviousBlockClientId** + +Returns the previous block's client ID from the given reference start ID. +Defaults start to the selected block. Returns null if there is no previous +block. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _startClientId_ `?string`: Optional client ID of block from which to search. + +_Returns_ + +- `?string`: Adjacent block's client ID, or null if none exists. + +# **getSelectedBlock** + +Returns the currently selected block, or null if there is no selected block. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?Object`: Selected block. + +# **getSelectedBlockClientId** + +Returns the currently selected block client ID, or null if there is no +selected block. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: Selected block client ID. + +# **getSelectedBlockClientIds** + +Returns the current selection set of block client IDs (multiselection or single selection). + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Array`: Multi-selected block client IDs. + +# **getSelectedBlockCount** + +Returns the number of blocks currently selected in the post. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `number`: Number of blocks selected in the post. + +# **getSelectedBlocksInitialCaretPosition** + +Returns the initial caret position for the selected block. +This position is to used to position the caret properly when the selected block changes. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?Object`: Selected block. + +# **getSelectionEnd** + +Returns the current selection end block client ID, attribute key and text +offset. + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `WPBlockSelection`: Selection end information. + +# **getSelectionStart** + +Returns the current selection start block client ID, attribute key and text +offset. + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `WPBlockSelection`: Selection start information. + +# **getSettings** + +Returns the editor settings. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Object`: The editor settings object. + +# **getTemplate** + +Returns the defined block template + +_Parameters_ + +- _state_ `boolean`: + +_Returns_ + +- `?Array`: Block Template + +# **getTemplateLock** + +Returns the defined block template lock. Optionally accepts a root block +client ID as context, otherwise defaulting to the global context. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional block root client ID. + +_Returns_ + +- `?string`: Block Template Lock + +# **hasInserterItems** + +Determines whether there are items to show in the inserter. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _rootClientId_ `?string`: Optional root client ID of block list. + +_Returns_ + +- `boolean`: Items that appear in inserter. + +# **hasMultiSelection** + +Returns true if a multi-selection has been made, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether multi-selection has been made. + +# **hasSelectedBlock** + +Returns true if there is a single selected block, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether a single block is selected. + +# **hasSelectedInnerBlock** + +Returns true if one of the block's inner blocks is selected. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. +- _deep_ `boolean`: Perform a deep check. + +_Returns_ + +- `boolean`: Whether the block as an inner block selected + +# **isAncestorMultiSelected** + +Returns true if an ancestor of the block is multi-selected, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Whether an ancestor of the block is in multi-selection set. + +# **isBlockInsertionPointVisible** + +Returns true if we should show the block insertion point. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?boolean`: Whether the insertion point is visible or not. + +# **isBlockMultiSelected** + +Returns true if the client ID occurs within the block multi-selection, or +false otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Whether block is in multi-selection set. + +# **isBlockSelected** + +Returns true if the block corresponding to the specified client ID is +currently selected and no multi-selection exists, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Whether block is selected and multi-selection exists. + +# **isBlockValid** + +Returns whether a block is valid or not. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Is Valid. + +# **isBlockWithinSelection** + +Returns true if the block corresponding to the specified client ID is +currently selected but isn't the last of the selected blocks. Here "last" +refers to the block sequence in the document, _not_ the sequence of +multi-selection, which is why `state.blockSelection.end` isn't used. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Whether block is selected and not the last in the selection. + +# **isCaretWithinFormattedText** + +Returns true if the caret is within formatted text, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the caret is within formatted text. + +# **isFirstMultiSelectedBlock** + +Returns true if a multi-selection exists, and the block corresponding to the +specified client ID is the first block of the multi-selection set, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `boolean`: Whether block is first in multi-selection. + +# **isLastBlockChangePersistent** + +Returns true if the most recent block change is be considered persistent, or +false otherwise. A persistent change is one committed by BlockEditorProvider +via its `onChange` callback, in addition to `onInput`. + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `boolean`: Whether the most recent block change was persistent. + +# **isMultiSelecting** + +Whether in the process of multi-selecting or not. This flag is only true +while the multi-selection is being selected (by mouse move), and is false +once the multi-selection has been settled. + +_Related_ + +- hasMultiSelection + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: True if multi-selecting, false if not. + +# **isNavigationMode** + +Returns whether the navigation mode is enabled. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Is navigation mode enabled. + +# **isSelectionEnabled** + +Selector that returns if multi-selection is enabled or not. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: True if it should be possible to multi-select blocks, false if multi-selection is disabled. + +# **isTyping** + +Returns true if the user is typing, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether user is typing. + +# **isValidTemplate** + +Returns whether the blocks matches the template or not. + +_Parameters_ + +- _state_ `boolean`: + +_Returns_ + +- `?boolean`: Whether the template is valid or not. + + + + +## Actions + + + +# **clearSelectedBlock** + +Returns an action object used in signalling that the block selection is cleared. + +_Returns_ + +- `Object`: Action object. + +# **enterFormattedText** + +Returns an action object used in signalling that the caret has entered formatted text. + +_Returns_ + +- `Object`: Action object. + +# **exitFormattedText** + +Returns an action object used in signalling that the user caret has exited formatted text. + +_Returns_ + +- `Object`: Action object. + +# **hideInsertionPoint** + +Returns an action object hiding the insertion point. + +_Returns_ + +- `Object`: Action object. + +# **insertBlock** + +Returns an action object used in signalling that a single block should be +inserted, optionally at a specific index respective a root block list. + +_Parameters_ + +- _block_ `Object`: Block object to insert. +- _index_ `?number`: Index at which block should be inserted. +- _rootClientId_ `?string`: Optional root client ID of block list on which to insert. +- _updateSelection_ `?boolean`: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +_Returns_ + +- `Object`: Action object. + +# **insertBlocks** + +Returns an action object used in signalling that an array of blocks should +be inserted, optionally at a specific index respective a root block list. + +_Parameters_ + +- _blocks_ `Array`: Block objects to insert. +- _index_ `?number`: Index at which block should be inserted. +- _rootClientId_ `?string`: Optional root client ID of block list on which to insert. +- _updateSelection_ `?boolean`: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +_Returns_ + +- `Object`: Action object. + +# **insertDefaultBlock** + +Returns an action object used in signalling that a new block of the default +type should be added to the block list. + +_Parameters_ + +- _attributes_ `?Object`: Optional attributes of the block to assign. +- _rootClientId_ `?string`: Optional root client ID of block list on which to append. +- _index_ `?number`: Optional index where to insert the default block + +_Returns_ + +- `Object`: Action object + +# **mergeBlocks** + +Returns an action object used in signalling that two blocks should be merged + +_Parameters_ + +- _firstBlockClientId_ `string`: Client ID of the first block to merge. +- _secondBlockClientId_ `string`: Client ID of the second block to merge. + +_Returns_ + +- `Object`: Action object. + +# **moveBlocksDown** + +Undocumented declaration. + +# **moveBlocksUp** + +Undocumented declaration. + +# **moveBlockToPosition** + +Returns an action object signalling that an indexed block should be moved +to a new index. + +_Parameters_ + +- _clientId_ `?string`: The client ID of the block. +- _fromRootClientId_ `?string`: Root client ID source. +- _toRootClientId_ `?string`: Root client ID destination. +- _index_ `number`: The index to move the block into. + +# **multiSelect** + +Returns an action object used in signalling that block multi-selection changed. + +_Parameters_ + +- _start_ `string`: First block of the multi selection. +- _end_ `string`: Last block of the multiselection. + +_Returns_ + +- `Object`: Action object. + +# **receiveBlocks** + +Returns an action object used in signalling that blocks have been received. +Unlike resetBlocks, these should be appended to the existing known set, not +replacing. + +_Parameters_ + +- _blocks_ `Array`: Array of block objects. + +_Returns_ + +- `Object`: Action object. + +# **removeBlock** + +Returns an action object used in signalling that the block with the +specified client ID is to be removed. + +_Parameters_ + +- _clientId_ `string`: Client ID of block to remove. +- _selectPrevious_ `boolean`: True if the previous block should be selected when a block is removed. + +_Returns_ + +- `Object`: Action object. + +# **removeBlocks** + +Yields action objects used in signalling that the blocks corresponding to +the set of specified client IDs are to be removed. + +_Parameters_ + +- _clientIds_ `(string|Array)`: Client IDs of blocks to remove. +- _selectPrevious_ `boolean`: True if the previous block should be selected when a block is removed. + +# **replaceBlock** + +Returns an action object signalling that a single block should be replaced +with one or more replacement blocks. + +_Parameters_ + +- _clientId_ `(string|Array)`: Block client ID to replace. +- _block_ `(Object|Array)`: Replacement block(s). + +_Returns_ + +- `Object`: Action object. + +# **replaceBlocks** + +Returns an action object signalling that a blocks should be replaced with +one or more replacement blocks. + +_Parameters_ + +- _clientIds_ `(string|Array)`: Block client ID(s) to replace. +- _blocks_ `(Object|Array)`: Replacement block(s). +- _indexToSelect_ `number`: Index of replacement block to select. + +# **replaceInnerBlocks** + +Returns an action object used in signalling that the inner blocks with the +specified client ID should be replaced. + +_Parameters_ + +- _rootClientId_ `string`: Client ID of the block whose InnerBlocks will re replaced. +- _blocks_ `Array`: Block objects to insert as new InnerBlocks +- _updateSelection_ `?boolean`: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +_Returns_ + +- `Object`: Action object. + +# **resetBlocks** + +Returns an action object used in signalling that blocks state should be +reset to the specified array of blocks, taking precedence over any other +content reflected as an edit in state. + +_Parameters_ + +- _blocks_ `Array`: Array of blocks. + +_Returns_ + +- `Object`: Action object. + +# **selectBlock** + +Returns an action object used in signalling that the block with the +specified client ID has been selected, optionally accepting a position +value reflecting its selection directionality. An initialPosition of -1 +reflects a reverse selection. + +_Parameters_ + +- _clientId_ `string`: Block client ID. +- _initialPosition_ `?number`: Optional initial position. Pass as -1 to reflect reverse selection. + +_Returns_ + +- `Object`: Action object. + +# **selectionChange** + +Returns an action object used in signalling that the user caret has changed +position. + +_Parameters_ + +- _clientId_ `string`: The selected block client ID. +- _attributeKey_ `string`: The selected block attribute key. +- _startOffset_ `number`: The start offset. +- _endOffset_ `number`: The end offset. + +_Returns_ + +- `Object`: Action object. + +# **selectNextBlock** + +Yields action objects used in signalling that the block following the given +clientId should be selected. + +_Parameters_ + +- _clientId_ `string`: Block client ID. + +# **selectPreviousBlock** + +Yields action objects used in signalling that the block preceding the given +clientId should be selected. + +_Parameters_ + +- _clientId_ `string`: Block client ID. + +# **setNavigationMode** + +Returns an action object used to enable or disable the navigation mode. + +_Parameters_ + +- _isNavigationMode_ `string`: Enable/Disable navigation mode. + +_Returns_ + +- `Object`: Action object + +# **setTemplateValidity** + +Returns an action object resetting the template validity. + +_Parameters_ + +- _isValid_ `boolean`: template validity flag. + +_Returns_ + +- `Object`: Action object. + +# **showInsertionPoint** + +Returns an action object used in signalling that the insertion point should +be shown. + +_Parameters_ + +- _rootClientId_ `?string`: Optional root client ID of block list on which to insert. +- _index_ `?number`: Index at which block should be inserted. + +_Returns_ + +- `Object`: Action object. + +# **startMultiSelect** + +Returns an action object used in signalling that a block multi-selection has started. + +_Returns_ + +- `Object`: Action object. + +# **startTyping** + +Returns an action object used in signalling that the user has begun to type. + +_Returns_ + +- `Object`: Action object. + +# **stopMultiSelect** + +Returns an action object used in signalling that block multi-selection stopped. + +_Returns_ + +- `Object`: Action object. + +# **stopTyping** + +Returns an action object used in signalling that the user has stopped typing. + +_Returns_ + +- `Object`: Action object. + +# **synchronizeTemplate** + +Returns an action object synchronize the template with the list of blocks + +_Returns_ + +- `Object`: Action object. + +# **toggleBlockMode** + +Returns an action object used to toggle the block editing mode between +visual and HTML modes. + +_Parameters_ + +- _clientId_ `string`: Block client ID. + +_Returns_ + +- `Object`: Action object. + +# **toggleSelection** + +Returns an action object that enables or disables block selection. + +_Parameters_ + +- _isSelectionEnabled_ `[boolean]`: Whether block selection should be enabled. + +_Returns_ + +- `Object`: Action object. + +# **updateBlock** + +Returns an action object used in signalling that the block with the +specified client ID has been updated. + +_Parameters_ + +- _clientId_ `string`: Block client ID. +- _updates_ `Object`: Block attributes to be merged. + +_Returns_ + +- `Object`: Action object. + +# **updateBlockAttributes** + +Returns an action object used in signalling that the block attributes with +the specified client ID has been updated. + +_Parameters_ + +- _clientId_ `string`: Block client ID. +- _attributes_ `Object`: Block attributes to be merged. + +_Returns_ + +- `Object`: Action object. + +# **updateBlockListSettings** + +Returns an action object that changes the nested settings of a given block. + +_Parameters_ + +- _clientId_ `string`: Client ID of the block whose nested setting are being received. +- _settings_ `Object`: Object with the new settings for the nested block. + +_Returns_ + +- `Object`: Action object + +# **updateSettings** + +Undocumented declaration. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-blocks.md b/gb-src/docs/designers-developers/developers/data/data-core-blocks.md new file mode 100644 index 0000000000000..5c6806fa70703 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-blocks.md @@ -0,0 +1,325 @@ +# Block Types Data + +Namespace: `core/blocks`. + +## Selectors + + + +# **getBlockStyles** + +Returns block styles by block name. + +_Parameters_ + +- _state_ `Object`: Data state. +- _name_ `string`: Block type name. + +_Returns_ + +- `?Array`: Block Styles. + +# **getBlockSupport** + +Returns the block support value for a feature, if defined. + +_Parameters_ + +- _state_ `Object`: Data state. +- _nameOrType_ `(string|Object)`: Block name or type object +- _feature_ `string`: Feature to retrieve +- _defaultSupports_ `*`: Default value to return if not explicitly defined + +_Returns_ + +- `?*`: Block support value + +# **getBlockType** + +Returns a block type by name. + +_Parameters_ + +- _state_ `Object`: Data state. +- _name_ `string`: Block type name. + +_Returns_ + +- `?Object`: Block Type. + +# **getBlockTypes** + +Returns all the available block types. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Array`: Block Types. + +# **getCategories** + +Returns all the available categories. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Array`: Categories list. + +# **getChildBlockNames** + +Returns an array with the child blocks of a given block. + +_Parameters_ + +- _state_ `Object`: Data state. +- _blockName_ `string`: Block type name. + +_Returns_ + +- `Array`: Array of child block names. + +# **getDefaultBlockName** + +Returns the name of the default block name. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `?string`: Default block name. + +# **getFreeformFallbackBlockName** + +Returns the name of the block for handling non-block content. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `?string`: Name of the block for handling non-block content. + +# **getGroupingBlockName** + +Returns the name of the block for handling unregistered blocks. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `?string`: Name of the block for handling unregistered blocks. + +# **getUnregisteredFallbackBlockName** + +Returns the name of the block for handling unregistered blocks. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `?string`: Name of the block for handling unregistered blocks. + +# **hasBlockSupport** + +Returns true if the block defines support for a feature, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Data state. +- _nameOrType_ `(string|Object)`: Block name or type object. +- _feature_ `string`: Feature to test. +- _defaultSupports_ `boolean`: Whether feature is supported by default if not explicitly defined. + +_Returns_ + +- `boolean`: Whether block supports feature. + +# **hasChildBlocks** + +Returns a boolean indicating if a block has child blocks or not. + +_Parameters_ + +- _state_ `Object`: Data state. +- _blockName_ `string`: Block type name. + +_Returns_ + +- `boolean`: True if a block contains child blocks and false otherwise. + +# **hasChildBlocksWithInserterSupport** + +Returns a boolean indicating if a block has at least one child block with inserter support. + +_Parameters_ + +- _state_ `Object`: Data state. +- _blockName_ `string`: Block type name. + +_Returns_ + +- `boolean`: True if a block contains at least one child blocks with inserter support and false otherwise. + +# **isMatchingSearchTerm** + +Returns true if the block type by the given name or object value matches a +search term, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Blocks state. +- _nameOrType_ `(string|Object)`: Block name or type object. +- _searchTerm_ `string`: Search term by which to filter. + +_Returns_ + +- `Array`: Whether block type matches search term. + + + + +## Actions + + + +# **addBlockStyles** + +Returns an action object used in signalling that new block styles have been added. + +_Parameters_ + +- _blockName_ `string`: Block name. +- _styles_ `(Array|Object)`: Block styles. + +_Returns_ + +- `Object`: Action object. + +# **addBlockTypes** + +Returns an action object used in signalling that block types have been added. + +_Parameters_ + +- _blockTypes_ `(Array|Object)`: Block types received. + +_Returns_ + +- `Object`: Action object. + +# **removeBlockStyles** + +Returns an action object used in signalling that block styles have been removed. + +_Parameters_ + +- _blockName_ `string`: Block name. +- _styleNames_ `(Array|string)`: Block style names. + +_Returns_ + +- `Object`: Action object. + +# **removeBlockTypes** + +Returns an action object used to remove a registered block type. + +_Parameters_ + +- _names_ `(string|Array)`: Block name. + +_Returns_ + +- `Object`: Action object. + +# **setCategories** + +Returns an action object used to set block categories. + +_Parameters_ + +- _categories_ `Array`: Block categories. + +_Returns_ + +- `Object`: Action object. + +# **setDefaultBlockName** + +Returns an action object used to set the default block name. + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `Object`: Action object. + +# **setFreeformFallbackBlockName** + +Returns an action object used to set the name of the block used as a fallback +for non-block content. + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `Object`: Action object. + +# **setGroupingBlockName** + +Returns an action object used to set the name of the block used +when grouping other blocks +eg: in "Group/Ungroup" interactions + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `Object`: Action object. + +# **setUnregisteredFallbackBlockName** + +Returns an action object used to set the name of the block used as a fallback +for unregistered blocks. + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `Object`: Action object. + +# **updateCategory** + +Returns an action object used to update a category. + +_Parameters_ + +- _slug_ `string`: Block category slug. +- _category_ `Object`: Object containing the category properties that should be updated. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-edit-post.md b/gb-src/docs/designers-developers/developers/data/data-core-edit-post.md new file mode 100644 index 0000000000000..93efda1bd0bc4 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-edit-post.md @@ -0,0 +1,475 @@ +# The Editor’s UI Data + +Namespace: `core/edit-post`. + +## Selectors + + + +# **getActiveGeneralSidebarName** + +Returns the current active general sidebar name, or null if there is no +general sidebar active. The active general sidebar is a unique name to +identify either an editor or plugin sidebar. + +Examples: + +- `edit-post/document` +- `my-plugin/insert-image-sidebar` + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: Active general sidebar name. + +# **getActiveMetaBoxLocations** + +Returns an array of active meta box locations. + +_Parameters_ + +- _state_ `Object`: Post editor state. + +_Returns_ + +- `Array`: Active meta box locations. + +# **getAllMetaBoxes** + +Returns the list of all the available meta boxes. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Array`: List of meta boxes. + +# **getEditorMode** + +Returns the current editing mode. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Editing mode. + +# **getMetaBoxesPerLocation** + +Returns the list of all the available meta boxes for a given location. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _location_ `string`: Meta box location to test. + +_Returns_ + +- `?Array`: List of meta boxes. + +# **getPreference** + +_Parameters_ + +- _state_ `Object`: Global application state. +- _preferenceKey_ `string`: Preference Key. +- _defaultValue_ `Mixed`: Default Value. + +_Returns_ + +- `Mixed`: Preference Value. + +# **getPreferences** + +Returns the preferences (these preferences are persisted locally). + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Object`: Preferences Object. + +# **hasMetaBoxes** + +Returns true if the post is using Meta Boxes + +_Parameters_ + +- _state_ `Object`: Global application state + +_Returns_ + +- `boolean`: Whether there are metaboxes or not. + +# **isEditorPanelEnabled** + +Returns true if the given panel is enabled, or false otherwise. Panels are +enabled by default. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _panelName_ `string`: A string that identifies the panel. + +_Returns_ + +- `boolean`: Whether or not the panel is enabled. + +# **isEditorPanelOpened** + +Returns true if the given panel is open, or false otherwise. Panels are +closed by default. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _panelName_ `string`: A string that identifies the panel. + +_Returns_ + +- `boolean`: Whether or not the panel is open. + +# **isEditorPanelRemoved** + +Returns true if the given panel was programmatically removed, or false otherwise. +All panels are not removed by default. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _panelName_ `string`: A string that identifies the panel. + +_Returns_ + +- `boolean`: Whether or not the panel is removed. + +# **isEditorSidebarOpened** + +Returns true if the editor sidebar is opened. + +_Parameters_ + +- _state_ `Object`: Global application state + +_Returns_ + +- `boolean`: Whether the editor sidebar is opened. + +# **isFeatureActive** + +Returns whether the given feature is enabled or not. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _feature_ `string`: Feature slug. + +_Returns_ + +- `boolean`: Is active. + +# **isMetaBoxLocationActive** + +Returns true if there is an active meta box in the given location, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Post editor state. +- _location_ `string`: Meta box location to test. + +_Returns_ + +- `boolean`: Whether the meta box location is active. + +# **isMetaBoxLocationVisible** + +Returns true if a metabox location is active and visible + +_Parameters_ + +- _state_ `Object`: Post editor state. +- _location_ `string`: Meta box location to test. + +_Returns_ + +- `boolean`: Whether the meta box location is active and visible. + +# **isModalActive** + +Returns true if a modal is active, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _modalName_ `string`: A string that uniquely identifies the modal. + +_Returns_ + +- `boolean`: Whether the modal is active. + +# **isPluginItemPinned** + +Returns true if the plugin item is pinned to the header. +When the value is not set it defaults to true. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _pluginName_ `string`: Plugin item name. + +_Returns_ + +- `boolean`: Whether the plugin item is pinned. + +# **isPluginSidebarOpened** + +Returns true if the plugin sidebar is opened. + +_Parameters_ + +- _state_ `Object`: Global application state + +_Returns_ + +- `boolean`: Whether the plugin sidebar is opened. + +# **isPublishSidebarOpened** + +Returns true if the publish sidebar is opened. + +_Parameters_ + +- _state_ `Object`: Global application state + +_Returns_ + +- `boolean`: Whether the publish sidebar is open. + +# **isSavingMetaBoxes** + +Returns true if the Meta Boxes are being saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the metaboxes are being saved. + + + + +## Actions + + + +# **closeGeneralSidebar** + +Returns an action object signalling that the user closed the sidebar. + +_Returns_ + +- `Object`: Action object. + +# **closeModal** + +Returns an action object signalling that the user closed a modal. + +_Returns_ + +- `Object`: Action object. + +# **closePublishSidebar** + +Returns an action object used in signalling that the user closed the +publish sidebar. + +_Returns_ + +- `Object`: Action object. + +# **hideBlockTypes** + +Returns an action object used in signalling that block types by the given +name(s) should be hidden. + +_Parameters_ + +- _blockNames_ `Array`: Names of block types to hide. + +_Returns_ + +- `Object`: Action object. + +# **metaBoxUpdatesSuccess** + +Returns an action object used signal a successful meta box update. + +_Returns_ + +- `Object`: Action object. + +# **openGeneralSidebar** + +Returns an action object used in signalling that the user opened an editor sidebar. + +_Parameters_ + +- _name_ `string`: Sidebar name to be opened. + +_Returns_ + +- `Object`: Action object. + +# **openModal** + +Returns an action object used in signalling that the user opened a modal. + +_Parameters_ + +- _name_ `string`: A string that uniquely identifies the modal. + +_Returns_ + +- `Object`: Action object. + +# **openPublishSidebar** + +Returns an action object used in signalling that the user opened the publish +sidebar. + +_Returns_ + +- `Object`: Action object + +# **removeEditorPanel** + +Returns an action object used to remove a panel from the editor. + +_Parameters_ + +- _panelName_ `string`: A string that identifies the panel to remove. + +_Returns_ + +- `Object`: Action object. + +# **requestMetaBoxUpdates** + +Returns an action object used to request meta box update. + +_Returns_ + +- `Object`: Action object. + +# **setAvailableMetaBoxesPerLocation** + +Returns an action object used in signaling +what Meta boxes are available in which location. + +_Parameters_ + +- _metaBoxesPerLocation_ `Object`: Meta boxes per location. + +_Returns_ + +- `Object`: Action object. + +# **showBlockTypes** + +Returns an action object used in signalling that block types by the given +name(s) should be shown. + +_Parameters_ + +- _blockNames_ `Array`: Names of block types to show. + +_Returns_ + +- `Object`: Action object. + +# **switchEditorMode** + +Undocumented declaration. + +# **toggleEditorPanelEnabled** + +Returns an action object used to enable or disable a panel in the editor. + +_Parameters_ + +- _panelName_ `string`: A string that identifies the panel to enable or disable. + +_Returns_ + +- `Object`: Action object. + +# **toggleEditorPanelOpened** + +Returns an action object used to open or close a panel in the editor. + +_Parameters_ + +- _panelName_ `string`: A string that identifies the panel to open or close. + +_Returns_ + +- `Object`: Action object. + +# **toggleFeature** + +Returns an action object used to toggle a feature flag. + +_Parameters_ + +- _feature_ `string`: Feature name. + +_Returns_ + +- `Object`: Action object. + +# **togglePinnedPluginItem** + +Returns an action object used to toggle a plugin name flag. + +_Parameters_ + +- _pluginName_ `string`: Plugin name. + +_Returns_ + +- `Object`: Action object. + +# **togglePublishSidebar** + +Returns an action object used in signalling that the user toggles the publish sidebar. + +_Returns_ + +- `Object`: Action object + +# **updatePreferredStyleVariations** + +Returns an action object used in signaling that a style should be auto-applied when a block is created. + +_Parameters_ + +- _blockName_ `string`: Name of the block. +- _blockStyle_ `?string`: Name of the style that should be auto applied. If undefined, the "auto apply" setting of the block is removed. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-editor.md b/gb-src/docs/designers-developers/developers/data/data-core-editor.md new file mode 100644 index 0000000000000..98fdd462dadee --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-editor.md @@ -0,0 +1,1406 @@ +# The Post Editor’s Data + +Namespace: `core/editor`. + +## Selectors + + + +# **canInsertBlockType** + +_Related_ + +- canInsertBlockType in core/block-editor store. + +# **canUserUseUnfilteredHTML** + +Returns whether or not the user has the unfiltered_html capability. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether the user can or can't post unfiltered HTML. + +# **didPostSaveRequestFail** + +Returns true if a previous post save was attempted but failed, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post save failed. + +# **didPostSaveRequestSucceed** + +Returns true if a previous post save was attempted successfully, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post was saved successfully. + +# **getActivePostLock** + +Returns the active post lock. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Object`: The lock object. + +# **getAdjacentBlockClientId** + +_Related_ + +- getAdjacentBlockClientId in core/block-editor store. + +# **getAutosave** + +> **Deprecated** since 5.6. Callers should use the `getAutosave( postType, postId, userId )` selector from the '@wordpress/core-data' package. + +Returns the current autosave, or null if one is not set (i.e. if the post +has yet to be autosaved, or has been saved or published since the last +autosave). + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?Object`: Current autosave, if exists. + +# **getAutosaveAttribute** + +> **Deprecated** since 5.6. Callers should use the `getAutosave( postType, postId, userId )` selector from the '@wordpress/core-data' package and access properties on the returned autosave object using getPostRawValue. + +Returns an attribute value of the current autosave revision for a post, or +null if there is no autosave for the post. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _attributeName_ `string`: Autosave attribute name. + +_Returns_ + +- `*`: Autosave attribute value. + +# **getBlock** + +_Related_ + +- getBlock in core/block-editor store. + +# **getBlockAttributes** + +_Related_ + +- getBlockAttributes in core/block-editor store. + +# **getBlockCount** + +_Related_ + +- getBlockCount in core/block-editor store. + +# **getBlockHierarchyRootClientId** + +_Related_ + +- getBlockHierarchyRootClientId in core/block-editor store. + +# **getBlockIndex** + +_Related_ + +- getBlockIndex in core/block-editor store. + +# **getBlockInsertionPoint** + +_Related_ + +- getBlockInsertionPoint in core/block-editor store. + +# **getBlockListSettings** + +_Related_ + +- getBlockListSettings in core/block-editor store. + +# **getBlockMode** + +_Related_ + +- getBlockMode in core/block-editor store. + +# **getBlockName** + +_Related_ + +- getBlockName in core/block-editor store. + +# **getBlockOrder** + +_Related_ + +- getBlockOrder in core/block-editor store. + +# **getBlockRootClientId** + +_Related_ + +- getBlockRootClientId in core/block-editor store. + +# **getBlocks** + +_Related_ + +- getBlocks in core/block-editor store. + +# **getBlocksByClientId** + +_Related_ + +- getBlocksByClientId in core/block-editor store. + +# **getBlockSelectionEnd** + +_Related_ + +- getBlockSelectionEnd in core/block-editor store. + +# **getBlockSelectionStart** + +_Related_ + +- getBlockSelectionStart in core/block-editor store. + +# **getBlocksForSerialization** + +> **Deprecated** since Gutenberg 6.2.0. + +Returns a set of blocks which are to be used in consideration of the post's +generated save content. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Array`: Filtered set of blocks for save. + +# **getClientIdsOfDescendants** + +_Related_ + +- getClientIdsOfDescendants in core/block-editor store. + +# **getClientIdsWithDescendants** + +_Related_ + +- getClientIdsWithDescendants in core/block-editor store. + +# **getCurrentPost** + +Returns the post currently being edited in its last known saved state, not +including unsaved edits. Returns an object containing relevant default post +values if the post has not yet been saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Object`: Post object. + +# **getCurrentPostAttribute** + +Returns an attribute value of the saved post. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _attributeName_ `string`: Post attribute name. + +_Returns_ + +- `*`: Post attribute value. + +# **getCurrentPostId** + +Returns the ID of the post currently being edited, or null if the post has +not yet been saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?number`: ID of current post. + +# **getCurrentPostLastRevisionId** + +Returns the last revision ID of the post currently being edited, +or null if the post has no revisions. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?number`: ID of the last revision. + +# **getCurrentPostRevisionsCount** + +Returns the number of revisions of the post currently being edited. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `number`: Number of revisions. + +# **getCurrentPostType** + +Returns the post type of the post currently being edited. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Post type. + +# **getEditedPostAttribute** + +Returns a single attribute of the post being edited, preferring the unsaved +edit if one exists, but falling back to the attribute for the last known +saved state of the post. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _attributeName_ `string`: Post attribute name. + +_Returns_ + +- `*`: Post attribute value. + +# **getEditedPostContent** + +Returns the content of the post being edited. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Post content. + +# **getEditedPostPreviewLink** + +Returns the post preview link + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: Preview Link. + +# **getEditedPostVisibility** + +Returns the current visibility of the post being edited, preferring the +unsaved value if different than the saved post. The return value is one of +"private", "password", or "public". + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Post visibility. + +# **getEditorBlocks** + +Return the current block list. + +_Parameters_ + +- _state_ `Object`: + +_Returns_ + +- `Array`: Block list. + +# **getEditorSettings** + +Returns the post editor settings. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Object`: The editor settings object. + +# **getFirstMultiSelectedBlockClientId** + +_Related_ + +- getFirstMultiSelectedBlockClientId in core/block-editor store. + +# **getGlobalBlockCount** + +_Related_ + +- getGlobalBlockCount in core/block-editor store. + +# **getInserterItems** + +_Related_ + +- getInserterItems in core/block-editor store. + +# **getLastMultiSelectedBlockClientId** + +_Related_ + +- getLastMultiSelectedBlockClientId in core/block-editor store. + +# **getMultiSelectedBlockClientIds** + +_Related_ + +- getMultiSelectedBlockClientIds in core/block-editor store. + +# **getMultiSelectedBlocks** + +_Related_ + +- getMultiSelectedBlocks in core/block-editor store. + +# **getMultiSelectedBlocksEndClientId** + +_Related_ + +- getMultiSelectedBlocksEndClientId in core/block-editor store. + +# **getMultiSelectedBlocksStartClientId** + +_Related_ + +- getMultiSelectedBlocksStartClientId in core/block-editor store. + +# **getNextBlockClientId** + +_Related_ + +- getNextBlockClientId in core/block-editor store. + +# **getPermalink** + +Returns the permalink for the post. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `?string`: The permalink, or null if the post is not viewable. + +# **getPermalinkParts** + +Returns the permalink for a post, split into it's three parts: the prefix, +the postName, and the suffix. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `Object`: An object containing the prefix, postName, and suffix for the permalink, or null if the post is not viewable. + +# **getPostEdits** + +Returns any post values which have been changed in the editor but not yet +been saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Object`: Object of key value pairs comprising unsaved edits. + +# **getPostLockUser** + +Returns details about the post lock user. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `Object`: A user object. + +# **getPreviousBlockClientId** + +_Related_ + +- getPreviousBlockClientId in core/block-editor store. + +# **getSelectedBlock** + +_Related_ + +- getSelectedBlock in core/block-editor store. + +# **getSelectedBlockClientId** + +_Related_ + +- getSelectedBlockClientId in core/block-editor store. + +# **getSelectedBlockCount** + +_Related_ + +- getSelectedBlockCount in core/block-editor store. + +# **getSelectedBlocksInitialCaretPosition** + +_Related_ + +- getSelectedBlocksInitialCaretPosition in core/block-editor store. + +# **getStateBeforeOptimisticTransaction** + +Returns state object prior to a specified optimist transaction ID, or `null` +if the transaction corresponding to the given ID cannot be found. + +_Parameters_ + +- _state_ `Object`: Current global application state. +- _transactionId_ `Object`: Optimist transaction ID. + +_Returns_ + +- `Object`: Global application state prior to transaction. + +# **getSuggestedPostFormat** + +Returns a suggested post format for the current post, inferred only if there +is a single block within the post and it is of a type known to match a +default post format. Returns null if the format cannot be determined. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: Suggested post format. + +# **getTemplate** + +_Related_ + +- getTemplate in core/block-editor store. + +# **getTemplateLock** + +_Related_ + +- getTemplateLock in core/block-editor store. + +# **hasAutosave** + +> **Deprecated** since 5.6. Callers should use the `getAutosave( postType, postId, userId )` selector from the '@wordpress/core-data' package and check for a truthy value. + +Returns the true if there is an existing autosave, otherwise false. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether there is an existing autosave. + +# **hasChangedContent** + +Returns true if content includes unsaved changes, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether content includes unsaved changes. + +# **hasEditorRedo** + +Returns true if any future editor history snapshots exist, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether redo history exists. + +# **hasEditorUndo** + +Returns true if any past editor history snapshots exist, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether undo history exists. + +# **hasInserterItems** + +_Related_ + +- hasInserterItems in core/block-editor store. + +# **hasMultiSelection** + +_Related_ + +- hasMultiSelection in core/block-editor store. + +# **hasSelectedBlock** + +_Related_ + +- hasSelectedBlock in core/block-editor store. + +# **hasSelectedInnerBlock** + +_Related_ + +- hasSelectedInnerBlock in core/block-editor store. + +# **inSomeHistory** + +Returns true if an optimistic transaction is pending commit, for which the +before state satisfies the given predicate function. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _predicate_ `Function`: Function given state, returning true if match. + +_Returns_ + +- `boolean`: Whether predicate matches for some history. + +# **isAncestorMultiSelected** + +_Related_ + +- isAncestorMultiSelected in core/block-editor store. + +# **isAutosavingPost** + +Returns true if the post is autosaving, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post is autosaving. + +# **isBlockInsertionPointVisible** + +_Related_ + +- isBlockInsertionPointVisible in core/block-editor store. + +# **isBlockMultiSelected** + +_Related_ + +- isBlockMultiSelected in core/block-editor store. + +# **isBlockSelected** + +_Related_ + +- isBlockSelected in core/block-editor store. + +# **isBlockValid** + +_Related_ + +- isBlockValid in core/block-editor store. + +# **isBlockWithinSelection** + +_Related_ + +- isBlockWithinSelection in core/block-editor store. + +# **isCaretWithinFormattedText** + +_Related_ + +- isCaretWithinFormattedText in core/block-editor store. + +# **isCleanNewPost** + +Returns true if there are no unsaved values for the current edit session and +if the currently edited post is new (has never been saved before). + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether new post and unsaved values exist. + +# **isCurrentPostPending** + +Returns true if post is pending review. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether current post is pending review. + +# **isCurrentPostPublished** + +Return true if the current post has already been published. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _currentPost_ `?Object`: Explicit current post for bypassing registry selector. + +_Returns_ + +- `boolean`: Whether the post has been published. + +# **isCurrentPostScheduled** + +Returns true if post is already scheduled. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether current post is scheduled to be posted. + +# **isEditedPostAutosaveable** + +Returns true if the post can be autosaved, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _autosave_ `Object`: A raw autosave object from the REST API. + +_Returns_ + +- `boolean`: Whether the post can be autosaved. + +# **isEditedPostBeingScheduled** + +Return true if the post being edited is being scheduled. Preferring the +unsaved status values. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post has been published. + +# **isEditedPostDateFloating** + +Returns whether the current post should be considered to have a "floating" +date (i.e. that it would publish "Immediately" rather than at a set time). + +Unlike in the PHP backend, the REST API returns a full date string for posts +where the 0000-00-00T00:00:00 placeholder is present in the database. To +infer that a post is set to publish "Immediately" we check whether the date +and modified date are the same. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether the edited post has a floating date value. + +# **isEditedPostDirty** + +Returns true if there are unsaved values for the current edit session, or +false if the editing state matches the saved or new post. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether unsaved values exist. + +# **isEditedPostEmpty** + +Returns true if the edited post has content. A post has content if it has at +least one saveable block or otherwise has a non-empty content property +assigned. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether post has content. + +# **isEditedPostNew** + +Returns true if the currently edited post is yet to be saved, or false if +the post has been saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post is new. + +# **isEditedPostPublishable** + +Return true if the post being edited can be published. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post can been published. + +# **isEditedPostSaveable** + +Returns true if the post can be saved, or false otherwise. A post must +contain a title, an excerpt, or non-empty content to be valid for save. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post can be saved. + +# **isFirstMultiSelectedBlock** + +_Related_ + +- isFirstMultiSelectedBlock in core/block-editor store. + +# **isMultiSelecting** + +_Related_ + +- isMultiSelecting in core/block-editor store. + +# **isPermalinkEditable** + +Returns whether the permalink is editable or not. + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `boolean`: Whether or not the permalink is editable. + +# **isPostAutosavingLocked** + +Returns whether post autosaving is locked. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Is locked. + +# **isPostLocked** + +Returns whether the post is locked. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Is locked. + +# **isPostLockTakeover** + +Returns whether the edition of the post has been taken over. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Is post lock takeover. + +# **isPostSavingLocked** + +Returns whether post saving is locked. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Is locked. + +# **isPreviewingPost** + +Returns true if the post is being previewed, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the post is being previewed. + +# **isPublishingPost** + +Returns true if the post is being published, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether post is being published. + +# **isPublishSidebarEnabled** + +Returns whether the pre-publish panel should be shown +or skipped when the user clicks the "publish" button. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the pre-publish panel should be shown or not. + +# **isSavingPost** + +Returns true if the post is currently being saved, or false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether post is being saved. + +# **isSelectionEnabled** + +_Related_ + +- isSelectionEnabled in core/block-editor store. + +# **isTyping** + +_Related_ + +- isTyping in core/block-editor store. + +# **isValidTemplate** + +_Related_ + +- isValidTemplate in core/block-editor store. + + + + +## Actions + + + +# **autosave** + +Action generator used in signalling that the post should autosave. + +_Parameters_ + +- _options_ `?Object`: Extra flags to identify the autosave. + +# **clearSelectedBlock** + +_Related_ + +- clearSelectedBlock in core/block-editor store. + +# **createUndoLevel** + +Returns an action object used in signalling that undo history record should +be created. + +_Returns_ + +- `Object`: Action object. + +# **disablePublishSidebar** + +Returns an action object used in signalling that the user has disabled the +publish sidebar. + +_Returns_ + +- `Object`: Action object + +# **editPost** + +Returns an action object used in signalling that attributes of the post have +been edited. + +_Parameters_ + +- _edits_ `Object`: Post attributes to edit. +- _options_ `Object`: Options for the edit. + +# **enablePublishSidebar** + +Returns an action object used in signalling that the user has enabled the +publish sidebar. + +_Returns_ + +- `Object`: Action object + +# **enterFormattedText** + +_Related_ + +- enterFormattedText in core/block-editor store. + +# **exitFormattedText** + +_Related_ + +- exitFormattedText in core/block-editor store. + +# **hideInsertionPoint** + +_Related_ + +- hideInsertionPoint in core/block-editor store. + +# **insertBlock** + +_Related_ + +- insertBlock in core/block-editor store. + +# **insertBlocks** + +_Related_ + +- insertBlocks in core/block-editor store. + +# **insertDefaultBlock** + +_Related_ + +- insertDefaultBlock in core/block-editor store. + +# **lockPostSaving** + +Returns an action object used to signal that post saving is locked. + +_Usage_ + + const { subscribe } = wp.data; + + const initialPostStatus = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); + + // Only allow publishing posts that are set to a future date. + if ( 'publish' !== initialPostStatus ) { + + // Track locking. + let locked = false; + + // Watch for the publish event. + let unssubscribe = subscribe( () => { + const currentPostStatus = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); + if ( 'publish' !== currentPostStatus ) { + + // Compare the post date to the current date, lock the post if the date isn't in the future. + const postDate = new Date( wp.data.select( 'core/editor' ).getEditedPostAttribute( 'date' ) ); + const currentDate = new Date(); + if ( postDate.getTime() <= currentDate.getTime() ) { + if ( ! locked ) { + locked = true; + wp.data.dispatch( 'core/editor' ).lockPostSaving( 'futurelock' ); + } + } else { + if ( locked ) { + locked = false; + wp.data.dispatch( 'core/editor' ).unlockPostSaving( 'futurelock' ); + } + } + } + } ); + } + +_Parameters_ + +- _lockName_ `string`: The lock name. + +_Returns_ + +- `Object`: Action object + +# **mergeBlocks** + +_Related_ + +- mergeBlocks in core/block-editor store. + +# **moveBlocksDown** + +_Related_ + +- moveBlocksDown in core/block-editor store. + +# **moveBlocksUp** + +_Related_ + +- moveBlocksUp in core/block-editor store. + +# **moveBlockToPosition** + +_Related_ + +- moveBlockToPosition in core/block-editor store. + +# **multiSelect** + +_Related_ + +- multiSelect in core/block-editor store. + +# **receiveBlocks** + +_Related_ + +- receiveBlocks in core/block-editor store. + +# **redo** + +Returns an action object used in signalling that undo history should +restore last popped state. + +# **refreshPost** + +Action generator for handling refreshing the current post. + +# **removeBlock** + +_Related_ + +- removeBlock in core/block-editor store. + +# **removeBlocks** + +_Related_ + +- removeBlocks in core/block-editor store. + +# **replaceBlock** + +_Related_ + +- replaceBlock in core/block-editor store. + +# **replaceBlocks** + +_Related_ + +- replaceBlocks in core/block-editor store. + +# **resetAutosave** + +> **Deprecated** since 5.6. Callers should use the `receiveAutosaves( postId, autosave )` selector from the '@wordpress/core-data' package. + +Returns an action object used in signalling that the latest autosave of the +post has been received, by initialization or autosave. + +_Parameters_ + +- _newAutosave_ `Object`: Autosave post object. + +_Returns_ + +- `Object`: Action object. + +# **resetBlocks** + +_Related_ + +- resetBlocks in core/block-editor store. + +# **resetEditorBlocks** + +Returns an action object used to signal that the blocks have been updated. + +_Parameters_ + +- _blocks_ `Array`: Block Array. +- _options_ `?Object`: Optional options. + +# **resetPost** + +Returns an action object used in signalling that the latest version of the +post has been received, either by initialization or save. + +_Parameters_ + +- _post_ `Object`: Post object. + +_Returns_ + +- `Object`: Action object. + +# **savePost** + +Action generator for saving the current post in the editor. + +_Parameters_ + +- _options_ `Object`: + +# **selectBlock** + +_Related_ + +- selectBlock in core/block-editor store. + +# **setTemplateValidity** + +_Related_ + +- setTemplateValidity in core/block-editor store. + +# **setupEditor** + +Returns an action generator used in signalling that editor has initialized with +the specified post object and editor settings. + +_Parameters_ + +- _post_ `Object`: Post object. +- _edits_ `Object`: Initial edited attributes object. +- _template_ `?Array`: Block Template. + +# **setupEditorState** + +Returns an action object used to setup the editor state when first opening +an editor. + +_Parameters_ + +- _post_ `Object`: Post object. + +_Returns_ + +- `Object`: Action object. + +# **showInsertionPoint** + +_Related_ + +- showInsertionPoint in core/block-editor store. + +# **startMultiSelect** + +_Related_ + +- startMultiSelect in core/block-editor store. + +# **startTyping** + +_Related_ + +- startTyping in core/block-editor store. + +# **stopMultiSelect** + +_Related_ + +- stopMultiSelect in core/block-editor store. + +# **stopTyping** + +_Related_ + +- stopTyping in core/block-editor store. + +# **synchronizeTemplate** + +_Related_ + +- synchronizeTemplate in core/block-editor store. + +# **toggleBlockMode** + +_Related_ + +- toggleBlockMode in core/block-editor store. + +# **toggleSelection** + +_Related_ + +- toggleSelection in core/block-editor store. + +# **trashPost** + +Action generator for trashing the current post in the editor. + +# **undo** + +Returns an action object used in signalling that undo history should pop. + +# **unlockPostSaving** + +Returns an action object used to signal that post saving is unlocked. + +_Usage_ + + // Unlock post saving with the lock key `mylock`: + wp.data.dispatch( 'core/editor' ).unlockPostSaving( 'mylock' ); + +_Parameters_ + +- _lockName_ `string`: The lock name. + +_Returns_ + +- `Object`: Action object + +# **updateBlock** + +_Related_ + +- updateBlock in core/block-editor store. + +# **updateBlockAttributes** + +_Related_ + +- updateBlockAttributes in core/block-editor store. + +# **updateBlockListSettings** + +_Related_ + +- updateBlockListSettings in core/block-editor store. + +# **updateEditorSettings** + +Undocumented declaration. + +# **updatePost** + +Returns an action object used in signalling that a patch of updates for the +latest version of the post have been received. + +_Parameters_ + +- _edits_ `Object`: Updated post fields. + +_Returns_ + +- `Object`: Action object. + +# **updatePostLock** + +Returns an action object used to lock the editor. + +_Parameters_ + +- _lock_ `Object`: Details about the post lock status, user, and nonce. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-notices.md b/gb-src/docs/designers-developers/developers/data/data-core-notices.md new file mode 100644 index 0000000000000..5721292549b70 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-notices.md @@ -0,0 +1,131 @@ +# Notices Data + +Namespace: `core/notices`. + +## Selectors + + + +# **getNotices** + +Returns all notices as an array, optionally for a given context. Defaults to +the global context. + +_Parameters_ + +- _state_ `Object`: Notices state. +- _context_ `?string`: Optional grouping context. + +_Returns_ + +- `Array`: Array of notices. + + + + +## Actions + + + +# **createErrorNotice** + +Returns an action object used in signalling that an error notice is to be +created. Refer to `createNotice` for options documentation. + +_Related_ + +- createNotice + +_Parameters_ + +- _content_ `string`: Notice message. +- _options_ `[Object]`: Optional notice options. + +_Returns_ + +- `Object`: Action object. + +# **createInfoNotice** + +Returns an action object used in signalling that an info notice is to be +created. Refer to `createNotice` for options documentation. + +_Related_ + +- createNotice + +_Parameters_ + +- _content_ `string`: Notice message. +- _options_ `[Object]`: Optional notice options. + +_Returns_ + +- `Object`: Action object. + +# **createNotice** + +Yields action objects used in signalling that a notice is to be created. + +_Parameters_ + +- _status_ `[string]`: Notice status. +- _content_ `string`: Notice message. +- _options_ `[Object]`: Notice options. +- _options.context_ `[string]`: Context under which to group notice. +- _options.id_ `[string]`: Identifier for notice. Automatically assigned if not specified. +- _options.isDismissible_ `[boolean]`: Whether the notice can be dismissed by user. +- _options.type_ `[string]`: Type of notice, one of `default`, or `snackbar`. +- _options.speak_ `[boolean]`: Whether the notice content should be announced to screen readers. +- _options.actions_ `[Array]`: User actions to be presented with notice. + +# **createSuccessNotice** + +Returns an action object used in signalling that a success notice is to be +created. Refer to `createNotice` for options documentation. + +_Related_ + +- createNotice + +_Parameters_ + +- _content_ `string`: Notice message. +- _options_ `[Object]`: Optional notice options. + +_Returns_ + +- `Object`: Action object. + +# **createWarningNotice** + +Returns an action object used in signalling that a warning notice is to be +created. Refer to `createNotice` for options documentation. + +_Related_ + +- createNotice + +_Parameters_ + +- _content_ `string`: Notice message. +- _options_ `[Object]`: Optional notice options. + +_Returns_ + +- `Object`: Action object. + +# **removeNotice** + +Returns an action object used in signalling that a notice is to be removed. + +_Parameters_ + +- _id_ `string`: Notice unique identifier. +- _context_ `[string]`: Optional context (grouping) in which the notice is intended to appear. Defaults to default context. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-nux.md b/gb-src/docs/designers-developers/developers/data/data-core-nux.md new file mode 100644 index 0000000000000..e937601ec864b --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-nux.md @@ -0,0 +1,100 @@ +# The NUX (New User Experience) Data + +Namespace: `core/nux`. + +## Selectors + + + +# **areTipsEnabled** + +Returns whether or not tips are globally enabled. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether tips are globally enabled. + +# **getAssociatedGuide** + +Returns an object describing the guide, if any, that the given tip is a part +of. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _tipId_ `string`: The tip to query. + +_Returns_ + +- `?NUX.GuideInfo`: Information about the associated guide. + +# **isTipVisible** + +Determines whether or not the given tip is showing. Tips are hidden if they +are disabled, have been dismissed, or are not the current tip in any +guide that they have been added to. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _tipId_ `string`: The tip to query. + +_Returns_ + +- `boolean`: Whether or not the given tip is showing. + + + + +## Actions + + + +# **disableTips** + +Returns an action object that, when dispatched, prevents all tips from +showing again. + +_Returns_ + +- `Object`: Action object. + +# **dismissTip** + +Returns an action object that, when dispatched, dismisses the given tip. A +dismissed tip will not show again. + +_Parameters_ + +- _id_ `string`: The tip to dismiss. + +_Returns_ + +- `Object`: Action object. + +# **enableTips** + +Returns an action object that, when dispatched, makes all tips show again. + +_Returns_ + +- `Object`: Action object. + +# **triggerGuide** + +Returns an action object that, when dispatched, presents a guide that takes +the user through a series of tips step by step. + +_Parameters_ + +- _tipIds_ `Array`: Which tips to show in the guide. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core-viewport.md b/gb-src/docs/designers-developers/developers/data/data-core-viewport.md new file mode 100644 index 0000000000000..b8f133834ec59 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core-viewport.md @@ -0,0 +1,50 @@ +# The Viewport Data + +Namespace: `core/viewport`. + +## Selectors + + + +# **isViewportMatch** + +Returns true if the viewport matches the given query, or false otherwise. + +_Usage_ + +```js +isViewportMatch( state, '< huge' ); +isViewPortMatch( state, 'medium' ); +``` + +_Parameters_ + +- _state_ `Object`: Viewport state object. +- _query_ `string`: Query string. Includes operator and breakpoint name, space separated. Operator defaults to >=. + +_Returns_ + +- `boolean`: Whether viewport matches query. + + + + +## Actions + + + +# **setIsMatching** + +Returns an action object used in signalling that viewport queries have been +updated. Values are specified as an object of breakpoint query keys where +value represents whether query matches. + +_Parameters_ + +- _values_ `Object`: Breakpoint query matches. + +_Returns_ + +- `Object`: Action object. + + diff --git a/gb-src/docs/designers-developers/developers/data/data-core.md b/gb-src/docs/designers-developers/developers/data/data-core.md new file mode 100644 index 0000000000000..ce0cda7d3542a --- /dev/null +++ b/gb-src/docs/designers-developers/developers/data/data-core.md @@ -0,0 +1,620 @@ +# WordPress Core Data + +Namespace: `core`. + +## Selectors + + + +# **canUser** + +Returns whether the current user can perform the given action on the given +REST resource. + +Calling this may trigger an OPTIONS request to the REST API via the +`canUser()` resolver. + + + +_Parameters_ + +- _state_ `Object`: Data state. +- _action_ `string`: Action to check. One of: 'create', 'read', 'update', 'delete'. +- _resource_ `string`: REST resource to check, e.g. 'media' or 'posts'. +- _id_ `[string]`: Optional ID of the rest resource to check. + +_Returns_ + +- `(boolean|undefined)`: Whether or not the user can perform the action, or `undefined` if the OPTIONS request is still being made. + +# **getAuthors** + +Returns all available authors. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Array`: Authors list. + +# **getAutosave** + +Returns the autosave for the post and author. + +_Parameters_ + +- _state_ `Object`: State tree. +- _postType_ `string`: The type of the parent post. +- _postId_ `number`: The id of the parent post. +- _authorId_ `number`: The id of the author. + +_Returns_ + +- `?Object`: The autosave for the post and author. + +# **getAutosaves** + +Returns the latest autosaves for the post. + +May return multiple autosaves since the backend stores one autosave per +author for each post. + +_Parameters_ + +- _state_ `Object`: State tree. +- _postType_ `string`: The type of the parent post. +- _postId_ `number`: The id of the parent post. + +_Returns_ + +- `?Array`: An array of autosaves for the post, or undefined if there is none. + +# **getCurrentUser** + +Returns the current user. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `Object`: Current user object. + +# **getEditedEntityRecord** + +Returns the specified entity record, merged with its edits. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `?Object`: The entity record, merged with its edits. + +# **getEmbedPreview** + +Returns the embed preview for the given URL. + +_Parameters_ + +- _state_ `Object`: Data state. +- _url_ `string`: Embedded URL. + +_Returns_ + +- `*`: Undefined if the preview has not been fetched, otherwise, the preview fetched from the embed preview API. + +# **getEntitiesByKind** + +Returns whether the entities for the give kind are loaded. + +_Parameters_ + +- _state_ `Object`: Data state. +- _kind_ `string`: Entity kind. + +_Returns_ + +- `boolean`: Whether the entities are loaded + +# **getEntity** + +Returns the entity object given its kind and name. + +_Parameters_ + +- _state_ `Object`: Data state. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. + +_Returns_ + +- `Object`: Entity + +# **getEntityRecord** + +Returns the Entity's record object by key. + +_Parameters_ + +- _state_ `Object`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `number`: Record's key + +_Returns_ + +- `?Object`: Record. + +# **getEntityRecordEdits** + +Returns the specified entity record's edits. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `?Object`: The entity record's edits. + +# **getEntityRecordNonTransientEdits** + +Returns the specified entity record's non transient edits. + +Transient edits don't create an undo level, and +are not considered for change detection. +They are defined in the entity's config. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `?Object`: The entity record's non transient edits. + +# **getEntityRecords** + +Returns the Entity's records. + +_Parameters_ + +- _state_ `Object`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `?Object`: Optional terms query. + +_Returns_ + +- `Array`: Records. + +# **getLastEntitySaveError** + +Returns the specified entity record's last save error. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `?Object`: The entity record's save error. + +# **getRawEntityRecord** + +Returns the entity's record object by key, +with its attributes mapped to their raw values. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `number`: Record's key. + +_Returns_ + +- `?Object`: Object with the entity's raw attributes. + +# **getRedoEdit** + +Returns the next edit from the current undo offset +for the entity records edits history, if any. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `?Object`: The edit. + +# **getReferenceByDistinctEdits** + +Returns a new reference when edited values have changed. This is useful in +inferring where an edit has been made between states by comparison of the +return values using strict equality. + +_Usage_ + + const hasEditOccurred = ( + getReferenceByDistinctEdits( beforeState ) !== + getReferenceByDistinctEdits( afterState ) + ); + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `*`: A value whose reference will change only when an edit occurs. + +# **getThemeSupports** + +Return theme supports data in the index. + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `*`: Index data. + +# **getUndoEdit** + +Returns the previous edit from the current undo offset +for the entity records edits history, if any. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `?Object`: The edit. + +# **getUserQueryResults** + +Returns all the users returned by a query ID. + +_Parameters_ + +- _state_ `Object`: Data state. +- _queryID_ `string`: Query ID. + +_Returns_ + +- `Array`: Users list. + +# **hasEditsForEntityRecord** + +Returns true if the specified entity record has edits, +and false otherwise. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `boolean`: Whether the entity record has edits or not. + +# **hasFetchedAutosaves** + +Returns true if the REST request for autosaves has completed. + +_Parameters_ + +- _state_ `Object`: State tree. +- _postType_ `string`: The type of the parent post. +- _postId_ `number`: The id of the parent post. + +_Returns_ + +- `boolean`: True if the REST request was completed. False otherwise. + +# **hasRedo** + +Returns true if there is a next edit from the current undo offset +for the entity records edits history, and false otherwise. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `boolean`: Whether there is a next edit or not. + +# **hasUndo** + +Returns true if there is a previous edit from the current undo offset +for the entity records edits history, and false otherwise. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `boolean`: Whether there is a previous edit or not. + +# **hasUploadPermissions** + +> **Deprecated** since 5.0. Callers should use the more generic `canUser()` selector instead of `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`. + +Returns whether the current user can upload media. + +Calling this may trigger an OPTIONS request to the REST API via the +`canUser()` resolver. + + + +_Parameters_ + +- _state_ `Object`: Data state. + +_Returns_ + +- `boolean`: Whether or not the user can upload media. Defaults to `true` if the OPTIONS request is being made. + +# **isAutosavingEntityRecord** + +Returns true if the specified entity record is autosaving, and false otherwise. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `boolean`: Whether the entity record is autosaving or not. + +# **isPreviewEmbedFallback** + +Determines if the returned preview is an oEmbed link fallback. + +WordPress can be configured to return a simple link to a URL if it is not embeddable. +We need to be able to determine if a URL is embeddable or not, based on what we +get back from the oEmbed preview API. + +_Parameters_ + +- _state_ `Object`: Data state. +- _url_ `string`: Embedded URL. + +_Returns_ + +- `boolean`: Is the preview for the URL an oEmbed link fallback. + +# **isRequestingEmbedPreview** + +Returns true if a request is in progress for embed preview data, or false +otherwise. + +_Parameters_ + +- _state_ `Object`: Data state. +- _url_ `string`: URL the preview would be for. + +_Returns_ + +- `boolean`: Whether a request is in progress for an embed preview. + +# **isSavingEntityRecord** + +Returns true if the specified entity record is saving, and false otherwise. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _recordId_ `number`: Record ID. + +_Returns_ + +- `boolean`: Whether the entity record is saving or not. + + + + +## Actions + + + +# **addEntities** + +Returns an action object used in adding new entities. + +_Parameters_ + +- _entities_ `Array`: Entities received. + +_Returns_ + +- `Object`: Action object. + +# **editEntityRecord** + +Returns an action object that triggers an +edit to an entity record. + +_Parameters_ + +- _kind_ `string`: Kind of the edited entity record. +- _name_ `string`: Name of the edited entity record. +- _recordId_ `number`: Record ID of the edited entity record. +- _edits_ `Object`: The edits. +- _options_ `Object`: Options for the edit. +- _options.undoIgnore_ `boolean`: Whether to ignore the edit in undo history or not. + +_Returns_ + +- `Object`: Action object. + +# **receiveAutosaves** + +Returns an action object used in signalling that the autosaves for a +post have been received. + +_Parameters_ + +- _postId_ `number`: The id of the post that is parent to the autosave. +- _autosaves_ `(Array|Object)`: An array of autosaves or singular autosave object. + +_Returns_ + +- `Object`: Action object. + +# **receiveCurrentUser** + +Returns an action used in signalling that the current user has been received. + +_Parameters_ + +- _currentUser_ `Object`: Current user object. + +_Returns_ + +- `Object`: Action object. + +# **receiveEmbedPreview** + +Returns an action object used in signalling that the preview data for +a given URl has been received. + +_Parameters_ + +- _url_ `string`: URL to preview the embed for. +- _preview_ `Mixed`: Preview data. + +_Returns_ + +- `Object`: Action object. + +# **receiveEntityRecords** + +Returns an action object used in signalling that entity records have been received. + +_Parameters_ + +- _kind_ `string`: Kind of the received entity. +- _name_ `string`: Name of the received entity. +- _records_ `(Array|Object)`: Records received. +- _query_ `?Object`: Query Object. +- _invalidateCache_ `?boolean`: Should invalidate query caches + +_Returns_ + +- `Object`: Action object. + +# **receiveThemeSupports** + +Returns an action object used in signalling that the index has been received. + +_Parameters_ + +- _themeSupports_ `Object`: Theme support for the current theme. + +_Returns_ + +- `Object`: Action object. + +# **receiveUploadPermissions** + +Returns an action object used in signalling that Upload permissions have been received. + +_Parameters_ + +- _hasUploadPermissions_ `boolean`: Does the user have permission to upload files? + +_Returns_ + +- `Object`: Action object. + +# **receiveUserPermission** + +Returns an action object used in signalling that the current user has +permission to perform an action on a REST resource. + +_Parameters_ + +- _key_ `string`: A key that represents the action and REST resource. +- _isAllowed_ `boolean`: Whether or not the user can perform the action. + +_Returns_ + +- `Object`: Action object. + +# **receiveUserQuery** + +Returns an action object used in signalling that authors have been received. + +_Parameters_ + +- _queryID_ `string`: Query ID. +- _users_ `(Array|Object)`: Users received. + +_Returns_ + +- `Object`: Action object. + +# **redo** + +Action triggered to redo the last undoed +edit to an entity record, if any. + +# **saveEditedEntityRecord** + +Action triggered to save an entity record's edits. + +_Parameters_ + +- _kind_ `string`: Kind of the entity. +- _name_ `string`: Name of the entity. +- _recordId_ `Object`: ID of the record. +- _options_ `Object`: Saving options. + +# **saveEntityRecord** + +Action triggered to save an entity record. + +_Parameters_ + +- _kind_ `string`: Kind of the received entity. +- _name_ `string`: Name of the received entity. +- _record_ `Object`: Record to be saved. +- _options_ `Object`: Saving options. + +# **undo** + +Action triggered to undo the last edit to +an entity record, if any. + + diff --git a/gb-src/docs/designers-developers/developers/feature-flags.md b/gb-src/docs/designers-developers/developers/feature-flags.md new file mode 100644 index 0000000000000..47a28d0fa42c3 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/feature-flags.md @@ -0,0 +1,110 @@ +# Feature Flags + +With phase 2 of the Gutenberg project there's a need for improved control over how code changes are released. Newer features developed for phase 2 and beyond should only be released to the Gutenberg plugin, while improvements and bug fixes should still continue to make their way into core releases. + +The technique for handling this is known as a 'feature flag'. + +## Introducing `process.env.GUTENBERG_PHASE` + +The `process.env.GUTENBERG_PHASE` is an environment variable containing a number that represents the phase. When the codebase is built for the plugin, this variable will be set to `2`. When building for core, it will be set to `1`. + +## Basic Use + +A phase 2 function or constant should be exported using the following ternary syntax: + +```js +function myPhaseTwoFeature() { + // implementation +} + +export const phaseTwoFeature = process.env.GUTENBERG_PHASE === 2 ? myPhaseTwoFeature : undefined; +``` + +In phase 1 environments the `phaseTwoFeature` export will be `undefined`. + +If you're attempting to import and call a phase 2 feature, be sure to wrap the call to the function in an if statement to avoid an error: +```js +import { phaseTwoFeature } from '@wordpress/foo'; + +if ( process.env.GUTENBERG_PHASE === 2) { + phaseTwoFeature(); +} +``` + +### How it works + +During the webpack build, any instances of `process.env.GUTENBERG_PHASE` will be replaced using webpack's define plugin (https://webpack.js.org/plugins/define-plugin/). + +If you write the following code: +```js +if ( process.env.GUTENBERG_PHASE === 2 ) { + phaseTwoFeature(); +} +``` + +When building the codebase for the plugin the variable will be replaced with the number literal `2`: +```js +if ( 2 === 2 ) { + phaseTwoFeature(); +} +``` + +Any code within the body of the if statement will be executed within the gutenberg plugin since `2 === 2` evaluates to `true`. + +For core, the `process.env.GUTENBERG_PHASE` variable is replaced with `1`, so the built code will look like: +```js +if ( 1 === 2 ) { + phaseTwoFeature(); +} +``` + +`1 === 2` evaluates to false so the phase 2 feature will not be executed within core. + +### Dead Code Elimination + +When building code for production, webpack 'minifies' code (https://en.wikipedia.org/wiki/Minification_(programming)), removing the amount of unnecessary JavaScript as much as possible. One of the steps involves something known as 'dead code elimination'. + +When the following code is encountered, webpack determines that the surrounding `if`statement is unnecessary: +```js +if ( 2 === 2 ) { + phaseTwoFeature(); +} +``` + + The condition will alway evaluates to `true`, so can be removed leaving just the code in the body: + ```js + phaseTwoFeature(); + ``` + +Similarly when building for core, the condition in the following `if` statement always resolves to false: +```js +if ( 1 === 2 ) { + phaseTwoFeature(); +} +``` + +The minification process will remove the entire `if` statement including the body, ensuring code destined for phase 2 is not included in the built JavaScript intended for core. + +## FAQ + +#### Why should I only use `===` or `!==` when comparing `process.env.GUTENBERG_PHASE` and not `>`, `>=`, `<` or `<=`? + +This is a restriction due to the behaviour of the greater than or less than operators in JavaScript when `process.env.GUTENBERG_PHASE` is undefined, as might be the case for third party users of WordPress npm packages. Both `process.env.GUTENBERG_PHASE < 2` and `process.env.GUTENBERG_PHASE > 1` resolve to false. When writing `if ( process.env.GUTENBERG_PHASE > 1 )`, the intention might be to avoid executing the phase 2 code in the following `if` statement's body. That's fine since it will evaluate to false. + +However, the following code doesn't quite have the intended behaviour: + +``` +function myPhaseTwoFeature() { + if ( process.env.GUTENBERG_PHASE < 2 ) { + return; + } + + // implementation of phase 2 feature +} +``` + +Here an early return is used to avoid execution of a phase 2 feature, but because the `if` condition resolves to false, the early return is bypassed and the phase 2 feature is incorrectly triggered. + +#### Why shouldn't I assign the result of an expression involving `GUTENBERG_PHASE` to a variable, e.g. `const isMyFeatureActive = process.env.GUTENBERG_PHASE === 2`? + +The aim here is to avoid introducing any complexity that could result in webpack's minifier not being able to eliminate dead code. See the [Dead Code Elimination](#dead-code-elimination) section for further details. diff --git a/gb-src/docs/designers-developers/developers/filters/README.md b/gb-src/docs/designers-developers/developers/filters/README.md new file mode 100644 index 0000000000000..a3877df555854 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/filters/README.md @@ -0,0 +1,7 @@ +# Filter Reference + +[Hooks](https://developer.wordpress.org/plugins/hooks/) are a way for one piece of code to interact/modify another piece of code. They provide one way for plugins and themes interact with the editor, but they’re also used extensively by WordPress Core itself. + +There are two types of hooks: [Actions](https://developer.wordpress.org/plugins/hooks/actions/) and [Filters](https://developer.wordpress.org/plugins/hooks/filters/). In addition to PHP actions and filters, WordPress also provides a mechanism for registering and executing hooks in JavaScript. This functionality is also available on npm as the [@wordpress/hooks](https://www.npmjs.com/package/@wordpress/hooks) package, for general purpose use. + +You can also learn more about both APIs: [PHP](https://codex.wordpress.org/Plugin_API/) and [JavaScript](/packages/hooks/README.md). diff --git a/gb-src/docs/designers-developers/developers/filters/autocomplete-filters.md b/gb-src/docs/designers-developers/developers/filters/autocomplete-filters.md new file mode 100644 index 0000000000000..1707080e867e8 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/filters/autocomplete-filters.md @@ -0,0 +1,88 @@ +# Autocomplete + +The `editor.Autocomplete.completers` filter is for extending and overriding the list of autocompleters used by blocks. + +The `Autocomplete` component found in `@wordpress/block-editor` applies this filter. The `@wordpress/components` package provides the foundational `Autocomplete` component that does not apply such a filter, but blocks should generally use the component provided by `@wordpress/block-editor`. + +### Example + +Here is an example of using the `editor.Autocomplete.completers` filter to add an acronym completer. You can find full documentation for the autocompleter interface with the `Autocomplete` component in the `@wordpress/components` package. + +{% codetabs %} +{% ES5 %} +```js +// Our completer +var acronymCompleter = { + name: 'acronyms', + triggerPrefix: '::', + options: [ + { letters: 'FYI', expansion: 'For Your Information' }, + { letters: 'AFAIK', expansion: 'As Far As I Know' }, + { letters: 'IIRC', expansion: 'If I Recall Correctly' }, + ], + getOptionKeywords: function( abbr ) { + var expansionWords = abbr.expansion.split( /\s+/ ); + return [ abbr.letters ].concat( expansionWords ); + }, + getOptionLabel: function( acronym ) { + return acronym.letters; + }, + getOptionCompletion: function( abbr ) { + return wp.element.createElement( + 'abbr', + { title: abbr.expansion }, + abbr.letters + ); + }, +}; + +// Our filter function +function appendAcronymCompleter( completers, blockName ) { + return blockName === 'my-plugin/foo' ? + completers.concat( acronymCompleter ) : + completers; +} + +// Adding the filter +wp.hooks.addFilter( + 'editor.Autocomplete.completers', + 'my-plugin/autocompleters/acronyms', + appendAcronymCompleter +); +``` +{% ESNext %} +```jsx +// Our completer +const acronymCompleter = { + name: 'acronyms', + triggerPrefix: '::', + options: [ + { letters: 'FYI', expansion: 'For Your Information' }, + { letters: 'AFAIK', expansion: 'As Far As I Know' }, + { letters: 'IIRC', expansion: 'If I Recall Correctly' }, + ], + getOptionKeywords( { letters, expansion } ) { + const expansionWords = expansion.split( /\s+/ ); + return [ letters, ...expansionWords ]; + }, + getOptionLabel: acronym => acronym.letters, + getOptionCompletion: ( { letters, expansion } ) => ( + { letters }, + ), +}; + +// Our filter function +function appendAcronymCompleter( completers, blockName ) { + return blockName === 'my-plugin/foo' ? + [ ...completers, acronymCompleter ] : + completers; +} + +// Adding the filter +wp.hooks.addFilter( + 'editor.Autocomplete.completers', + 'my-plugin/autocompleters/acronym', + appendAcronymCompleter +); +``` +{% end %} diff --git a/gb-src/docs/designers-developers/developers/filters/block-filters.md b/gb-src/docs/designers-developers/developers/filters/block-filters.md new file mode 100644 index 0000000000000..b0be75557076a --- /dev/null +++ b/gb-src/docs/designers-developers/developers/filters/block-filters.md @@ -0,0 +1,429 @@ +# Block Filters + +To modify the behavior of existing blocks, WordPress exposes several APIs: + +### Block Style Variations + +Block Style Variations allow providing alternative styles to existing blocks. They work by adding a className to the block's wrapper. This className can be used to provide an alternative styling for the block if the style variation is selected. See the [Getting Started with JavaScript tutorial](/docs/designers-developers/developers/tutorials/javascript/) for a full example. + +_Example:_ + +```js +wp.blocks.registerBlockStyle( 'core/quote', { + name: 'fancy-quote', + label: 'Fancy Quote' +} ); +``` + +The example above registers a block style variation named `fancy-quote` to the `core/quote` block. When the user selects this block style variation from the styles selector, an `is-style-fancy-quote` className will be added to the block's wrapper. + +By adding `isDefault: true` you can mark the registered style variation as the one that is recognized as active when no custom class name is provided. It also means that there will be no custom class name added to the HTML output for the style that is marked as default. + +To remove a block style variation use `wp.blocks.unregisterBlockStyle()`. + +_Example:_ + +```js +wp.blocks.unregisterBlockStyle( 'core/quote', 'large' ); +``` + +The above removes the variation named `large` from the `core/quote` block. + +**Important:** When unregistering a block style, there can be a [race condition](https://en.wikipedia.org/wiki/Race_condition) on which code runs first: registering the style, or unregistering the style. You want your unregister code to run last. The way to do that is specify the component that is registering the style as a dependency, in this case `wp-edit-post`. Additionally, using `wp.domReady()` ensures the unregister code runs once the dom is loaded. + +Enqueue your JavaScript with the following PHP code: + +```php +function myguten_enqueue() { + wp_enqueue_script( + 'myguten-script', + plugins_url( 'myguten.js', __FILE__ ), + array( 'wp-blocks', 'wp-dom-ready', 'wp-edit-post' ), + filemtime( plugin_dir_path( __FILE__ ) . '/myguten.js' ) + ); +} +add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' ); +``` + +The JavaScript code in `myguten.js`: + +```js +wp.domReady( function() { + wp.blocks.unregisterBlockStyle( 'core/quote', 'large' ); +} ); +``` + +### Server-side registration helper + +While the samples provided do allow full control of block styles, they do require a considerable amount of code. + +To simplify the process of registering and unregistering block styles, two server-side functions are also available: `register_block_style`, and `unregister_block_style`. + +#### register_block_style + +The `register_block_style` function receives the name of the block as the first argument and an array describing properties of the style as the second argument. + +The properties of the style array must include `name` and `label`: + - `name`: The identifier of the style used to compute a CSS class. + - `label`: A human-readable label for the style. + +Besides the two mandatory properties, the styles properties array should also include an `inline_style` or a `style_handle` property: + + - `inline_style`: Contains inline CSS code that registers the CSS class required for the style. + - `style_handle`: Contains the handle to an already registered style that should be enqueued in places where block styles are needed. + +The following code sample registers a style for the quote block named "Blue Quote", and provides an inline style that makes quote blocks with the "Blue Quote" style have blue color: + +```php +register_block_style( + 'core/quote', + array( + 'name' => 'blue-quote', + 'label' => __( 'Blue Quote' ), + 'inline_style' => '.wp-block-quote.is-style-blue-quote { color: blue; }', + ) +); +``` + +Alternatively, if a stylesheet was already registered which contains the CSS for the style variation, it is possible to just pass the stylesheet's handle so `register_block_style` function will make sure it is enqueue. + +The following code sample provides an example of this use case: + +```php +wp_register_style( 'myguten-style', get_template_directory_uri() . '/custom-style.css' ); + +// ... + +register_block_style( + 'core/quote', + array( + 'name' => 'fancy-quote', + 'label' => 'Fancy Quote', + 'style_handle' => 'myguten-style', + ) +); +``` + +#### unregister_block_style + +`unregister_block_style` allows unregistering a block style previously registered on the server using `register_block_style`. + +The function's first argument is the registered name of the block, and the name of the style as the second argument. + +The following code sample unregisteres the style named 'fancy-quote' from the quote block: + +```php +unregister_block_style( 'core/quote', 'fancy-quote' ); +``` + +**Important:** The function `unregister_block_style` only unregisters styles that were registered on the server using `register_block_style`. The function does not unregister a style registered using client-side code. + +### Filters + +Extending blocks can involve more than just providing alternative styles, in this case, you can use one of the following filters to extend the block settings. + +#### `blocks.registerBlockType` + +Used to filter the block settings. It receives the block settings and the name of the registered block as arguments. Since v6.1.0 this filter is also applied to each of a block's deprecated settings. + +_Example:_ + +Ensure that List blocks are saved with the canonical generated class name (`wp-block-list`): + +```js +function addListBlockClassName( settings, name ) { + if ( name !== 'core/list' ) { + return settings; + } + + return lodash.assign( {}, settings, { + supports: lodash.assign( {}, settings.supports, { + className: true + } ), + } ); +} + +wp.hooks.addFilter( + 'blocks.registerBlockType', + 'my-plugin/class-names/list-block', + addListBlockClassName +); +``` + +#### `blocks.getSaveElement` + +A filter that applies to the result of a block's `save` function. This filter is used to replace or extend the element, for example using `wp.element.cloneElement` to modify the element's props or replace its children, or returning an entirely new element. + +The filter's callback receives an element, a block type and the block attributes as arguments. It should return an element. + +#### `blocks.getSaveContent.extraProps` + +A filter that applies to all blocks returning a WP Element in the `save` function. This filter is used to add extra props to the root element of the `save` function. For example: to add a className, an id, or any valid prop for this element. + +The filter receives the current `save` element's props, a block type and the block attributes as arguments. It should return a props object. + +_Example:_ + +Adding a background by default to all blocks. + +```js +function addBackgroundColorStyle( props ) { + return lodash.assign( props, { style: { backgroundColor: 'red' } } ); +} + +wp.hooks.addFilter( + 'blocks.getSaveContent.extraProps', + 'my-plugin/add-background-color-style', + addBackgroundColorStyle +); +``` + +_Note:_ This filter must always be run on every page load, and not in your browser's developer tools console. Otherwise, a [block validation](/docs/designers-developers/developers/block-api/block-edit-save.md#validation) error will occur the next time the post is edited. This is due to the fact that block validation occurs by verifying that the saved output matches what is stored in the post's content during editor initialization. So, if this filter does not exist when the editor loads, the block will be marked as invalid. + +#### `blocks.getBlockDefaultClassName` + +Generated HTML classes for blocks follow the `wp-block-{name}` nomenclature. This filter allows to provide an alternative class name. + +_Example:_ + +```js +// Our filter function +function setBlockCustomClassName( className, blockName ) { + return blockName === 'core/code' ? + 'my-plugin-code' : + className; +} + +// Adding the filter +wp.hooks.addFilter( + 'blocks.getBlockDefaultClassName', + 'my-plugin/set-block-custom-class-name', + setBlockCustomClassName +); +``` + +#### `blocks.switchToBlockType.transformedBlock` + +Used to filter an individual transform result from block transformation. All of the original blocks are passed since transformations are many-to-many, not one-to-one. + +#### `blocks.getBlockAttributes` + +Called immediately after the default parsing of a block's attributes and before validation to allow a plugin to manipulate attribute values in time for validation and/or the initial values rendering of the block in the editor. + +#### `editor.BlockEdit` + +Used to modify the block's `edit` component. It receives the original block `BlockEdit` component and returns a new wrapped component. + +_Example:_ + +{% codetabs %} +{% ES5 %} +```js +var el = wp.element.createElement; + +var withInspectorControls = wp.compose.createHigherOrderComponent( function( BlockEdit ) { + return function( props ) { + return el( + wp.element.Fragment, + {}, + el( + BlockEdit, + props + ), + el( + wp.editor.InspectorControls, + {}, + el( + wp.components.PanelBody, + {}, + 'My custom control' + ) + ) + ); + }; +}, 'withInspectorControls' ); + +wp.hooks.addFilter( 'editor.BlockEdit', 'my-plugin/with-inspector-controls', withInspectorControls ); +``` +{% ESNext %} +```js +const { createHigherOrderComponent } = wp.compose; +const { Fragment } = wp.element; +const { InspectorControls } = wp.editor; +const { PanelBody } = wp.components; + +const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + return ( + + + + + My custom control + + + + ); + }; +}, "withInspectorControl" ); + +wp.hooks.addFilter( 'editor.BlockEdit', 'my-plugin/with-inspector-controls', withInspectorControls ); +``` +{% end %} + +#### `editor.BlockListBlock` + +Used to modify the block's wrapper component containing the block's `edit` component and all toolbars. It receives the original `BlockListBlock` component and returns a new wrapped component. + +_Example:_ + +{% codetabs %} +{% ES5 %} + +```js +var el = wp.element.createElement; + +var withClientIdClassName = wp.compose.createHigherOrderComponent( function( BlockListBlock ) { + return function( props ) { + var newProps = lodash.assign( + {}, + props, + { + className: "block-" + props.clientId, + } + ); + + return el( + BlockListBlock, + newProps + ); + }; +}, 'withClientIdClassName' ); + +wp.hooks.addFilter( 'editor.BlockListBlock', 'my-plugin/with-client-id-class-name', withClientIdClassName ); + +``` +{% ESNext %} +```js +const { createHigherOrderComponent } = wp.compose; + +const withClientIdClassName = createHigherOrderComponent( ( BlockListBlock ) => { + return ( props ) => { + return ; + }; +}, 'withClientIdClassName' ); + +wp.hooks.addFilter( 'editor.BlockListBlock', 'my-plugin/with-client-id-class-name', withClientIdClassName ); +``` + +{% end %} + +## Removing Blocks + +### Using a blacklist + +Adding blocks is easy enough, removing them is as easy. Plugin or theme authors have the possibility to "unregister" blocks. + +```js +// my-plugin.js +wp.domReady( function() { + wp.blocks.unregisterBlockType( 'core/verse' ); +} ); +``` + +and load this script in the Editor + +```php +post_type !== 'post' ) { + return $allowed_block_types; + } + return array( 'core/paragraph' ); +} + +add_filter( 'allowed_block_types', 'my_plugin_allowed_block_types', 10, 2 ); +``` + +## Managing block categories + +It is possible to filter the list of default block categories using the `block_categories` filter. You can do it on the server by implementing a function which returns a list of categories. It is going to be used during blocks registration and to group blocks in the inserter. You can also use the second provided param `$post` to generate a different list depending on the post's content. + +```php +post_type !== 'post' ) { + return $categories; + } + return array_merge( + $categories, + array( + array( + 'slug' => 'my-category', + 'title' => __( 'My category', 'my-plugin' ), + 'icon' => 'wordpress', + ), + ) + ); +} +add_filter( 'block_categories', 'my_plugin_block_categories', 10, 2 ); +``` + +You can also display an icon with your block category by setting an `icon` attribute. The value can be the slug of a [WordPress Dashicon](https://developer.wordpress.org/resource/dashicons/). + +It is possible to set an SVG as the icon of the category if a custom icon is needed. To do so, the icon should be rendered and set on the frontend, so it can make use of WordPress SVG, allowing mobile compatibility and making the icon more accessible. + +To set an SVG icon for the category shown in the previous example, add the following example JavaScript code to the editor calling `wp.blocks.updateCategory` e.g: +```js +( function() { + var el = wp.element.createElement; + var SVG = wp.components.SVG; + var circle = el( 'circle', { cx: 10, cy: 10, r: 10, fill: 'red', stroke: 'blue', strokeWidth: '10' } ); + var svgIcon = el( SVG, { width: 20, height: 20, viewBox: '0 0 20 20'}, circle); + wp.blocks.updateCategory( 'my-category', { icon: svgIcon } ); +} )(); +``` + diff --git a/gb-src/docs/designers-developers/developers/filters/editor-filters.md b/gb-src/docs/designers-developers/developers/filters/editor-filters.md new file mode 100644 index 0000000000000..16c30fc3179aa --- /dev/null +++ b/gb-src/docs/designers-developers/developers/filters/editor-filters.md @@ -0,0 +1,52 @@ +# Editor Filters (Experimental) +To modify the behavior of the editor experience, the following Filters are exposed: + +### `editor.PostFeaturedImage.imageSize` + +Used to modify the image size displayed in the Post Featured Image component. It defaults to `'post-thumbnail'`, and will fail back to the `full` image size when the specified image size doesn't exist in the media object. It's modeled after the `admin_post_thumbnail_size` filter in the classic editor. + +_Example:_ + +```js +var withImageSize = function( size, mediaId, postId ) { + return 'large'; +}; + +wp.hooks.addFilter( 'editor.PostFeaturedImage.imageSize', 'my-plugin/with-image-size', withImageSize ); +``` + +### `editor.PostPreview.interstitialMarkup` + +Filters the interstitial message shown when generating previews. + +_Example:_ + +```js +var customPreviewMessage = function() { + return 'Post preview is being generated!'; +}; + +wp.hooks.addFilter( 'editor.PostPreview.interstitialMarkup', 'my-plugin/custom-preview-message', customPreviewMessage ); +``` + +## Editor settings + +### `block_editor_settings` +This is a PHP filter which is applied before sending settings to the WordPress block editor. + +You may find details about this filter [on its WordPress Code Reference page](https://developer.wordpress.org/reference/hooks/block_editor_settings/). + +The filter will send any setting to the initialized Editor, which means any editor setting that is used to configure the editor at initialisation can be filtered by a PHP WordPress plugin before being sent. + +### Available default editor settings + +#### `richEditingEnabled` +If it is `true` the user can edit the content using the Visual Editor. + +It is set by default to the return value of the [`user_can_richedit`](https://developer.wordpress.org/reference/functions/user_can_richedit/) function. It checks if the user can access the Visual Editor and whether it’s supported by the user’s browser. + + +#### `codeEditingEnabled` +Default `true`. Indicates whether the user can access the Code Editor **in addition** to the Visual Editor. + +If set to false the user will not be able to switch between Visual and Code editor. The option in the settings menu will not be available and the keyboard shortcut for switching editor types will not fire. diff --git a/gb-src/docs/designers-developers/developers/filters/parser-filters.md b/gb-src/docs/designers-developers/developers/filters/parser-filters.md new file mode 100644 index 0000000000000..3acfb2489182d --- /dev/null +++ b/gb-src/docs/designers-developers/developers/filters/parser-filters.md @@ -0,0 +1,36 @@ +# Parser Filters + +When the editor is interacting with blocks, these are stored in memory as data structures comprising a few basic properties and attributes. Upon saving a working post we serialize these data structures into a specific HTML structure and save the resultant string into the `post_content` property of the post in the WordPress database. When we load that post back into the editor we have to make the reverse transformation to build those data structures from the serialized format in HTML. + +The process of loading the serialized HTML into the editor is performed by the _block parser_. The formal specification for this transformation is encoded in the parsing expression grammar (PEG) inside the `@wordpress/block-serialization-spec-parser` package. The editor provides a default parser implementation of this grammar but there may be various reasons for replacing that implementation with a custom implementation. We can inject our own custom parser implementation through the appropriate filter. + +## Server-side parser + +Plugins have access to the parser if they want to process posts in their structured form instead of a plain HTML-as-string representation. + +## Client-side parser + +The editor uses the client-side parser while interactively working in a post. The plain HTML-as-string representation is sent to the browser by the backend and then the editor performs the first parse to initialize itself. + +## Filters + +To replace the server-side parser, use the `block_parser_class` filter. The filter transforms the string class name of a parser class. This class is expected to expose a `parse` method. + +_Example:_ + +```php +class EmptyParser { + public function parse( $post_content ) { + // return an empty document + return array(); + } +} + +function my_plugin_select_empty_parser( $prev_parser_class ) { + return 'EmptyParser'; +} + +add_filter( 'block_parser_class', 'my_plugin_select_empty_parser', 10, 1 ); +``` + +> **Note**: At the present time it's not possible to replace the client-side parser. diff --git a/gb-src/docs/designers-developers/developers/internationalization.md b/gb-src/docs/designers-developers/developers/internationalization.md new file mode 100644 index 0000000000000..fa30d0b14cc7e --- /dev/null +++ b/gb-src/docs/designers-developers/developers/internationalization.md @@ -0,0 +1,262 @@ +# Internationalization + +## What is Internationalization? + +Internationalization is the process to provide multiple language support to software, in this case WordPress. Internationalization is often abbreviated as **i18n**, where 18 stands for the number of letters between the first _i_ and the last _n_. + +Providing i18n support to your plugin and theme allows it to reach the largest possible audience, even without requiring you to provide the additional language translations. When you upload your software to WordPress.org, all JS and PHP files will automatically be parsed. Any detected translation strings are added to [translate.wordpress.org](https://translate.wordpress.org/) to allow the community to translate, ensuring WordPress plugins and themes are available in as many languages as possible. + +For PHP, WordPress has a long established process, see [How to Internationalize Your Plugin](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/). The release of WordPress 5.0 brings a similar process for translation to JavaScript code. + +## How to use i18n in JavaScript + +WordPress 5.0 introduced the wp-i18n JavaScript package that provides the functions needed to add translatable strings as you would in PHP. + +First, add **wp-i18n** as a dependency when registering your script: + +```php + 'myguten-script', + ) ); +} +add_action( 'init', 'myguten_block_init' ); +``` + +In your code, you can include the i18n functions. The most common function is **__** (a double underscore) which provides translation of a simple string. Here is a basic static block example, this is in a file called `block.js`: + +{% codetabs %} +{% ES5 %} +```js +const { __ } = wp.i18n; +const el = wp.element.createElement; +const { registerBlockType } = wp.blocks; + +registerBlockType( 'myguten/simple', { + title: __('Simple Block', 'myguten'), + category: 'widgets', + + edit: () => { + return el( + 'p', + { style: { color:'red'}, }, + __('Hello World', 'myguten') + ); + }, + + save: () => { + return el( + 'p', + { style: { color:'red'}, }, + __('Hello World', 'myguten') + ); + } +}); +``` +{% ESNext %} +```js +const { __ } = wp.i18n; +const { registerBlockType } = wp.blocks; + +registerBlockType( 'myguten/simple', { + title: __('Simple Block', 'myguten'), + category: 'widgets', + + edit: () => { + return ( +

+ { __('Hello World', 'myguten') } +

+ ); + }, + + save: () => { + return ( +

+ { __('Hello World', 'myguten') } +

+ ); + } +}); +``` +{% end %} + +In the above example, the function will use the first argument for the string to be translated. The second argument is the text domain which must match the text domain slug specified by your plugin. + +Common functions available, these mirror their PHP counterparts are: + +- `__( 'Hello World', 'my-text-domain' )` - Translate a certain string. +- `_n( '%s Comment', '%s Comments', numberOfComments, 'my-text-domain' )` - Translate and retrieve the singular or plural form based on the supplied number. +- `_x( 'Default', 'block style', 'my-text-domain' )` - Translate a certain string with some additional context. + +**Note:** Every string displayed to the user should be wrapped in an i18n function. + +After all strings in your code is wrapped, the final step is to tell WordPress your JavaScript contains translations, using the [wp_set_script_translations()](https://developer.wordpress.org/reference/functions/wp_set_script_translations/) function. + +```php +\n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2019-03-08T11:26:56-08:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"X-Generator: WP-CLI 2.1.0\n" +"X-Domain: myguten\n" + +#. Plugin Name of the plugin +msgid "Scratch Plugin" +msgstr "" + +#: block.js:6 +msgid "Simple Block" +msgstr "" + +#: block.js:13 +#: block.js:21 +msgid "Hello World" +msgstr "" +``` + +Here, `msgid` is the string to be translated, and `msgstr` is the actual translation. In the POT file, `msgstr` will always be empty. + +This POT file can then be used as the template for new translations. You should **copy the file** using the language code you are going to translate, this example will use the Esperanto (eo) language: + +``` +cp myguten.pot myguten-eo.po +``` + +For this simple example, you can simply edit the `.po` file in your editor and add the translation to all the `msgstr` sets. For a larger, more complex set of translation, the [GlotPress](https://glotpress.blog/) and [Poedit](https://poedit.net/) tools exist to help. + +You need also to add the `Language: eo` parameter. Here is full `myguten-eo.po` translated file + +``` +# Copyright (C) 2019 +# This file is distributed under the same license as the Scratch Plugin plugin. +msgid "" +msgstr "" +"Project-Id-Version: Scratch Plugin\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/scratch\n" +"Last-Translator: Marcus Kazmierczak \n" +"Language-Team: Esperanto \n" +"Language: eo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2019-02-18T07:20:46-08:00\n" +"PO-Revision-Date: 2019-02-18 08:16-0800\n" +"X-Generator: Poedit 2.2.1\n" +"X-Domain: myguten\n" + +#. Plugin Name of the plugin +msgid "Scratch Plugin" +msgstr "Scratch kromprogrameto" + +#: block.js:6 +msgid "Simple Block" +msgstr "Simpla bloko" + +#: block.js:13 block.js:21 +msgid "Hello World" +msgstr "Saltuon mundo" +``` + +The last step to create the translation file is to convert the `myguten-eo.po` to the JSON format needed. For this, you can use WP-CLI's [`wp i18n make-json` command](https://developer.wordpress.org/cli/commands/i18n/make-json/), which requires WP-CLI v2.2.0 and later. + +``` +wp i18n make-json myguten-eo.po --no-purge +``` + +This will generate the JSON file `myguten-eo-[md5].json` with the contents: + +```json +{ + "translation-revision-date": "2019-04-26T13:30:11-07:00", + "generator": "WP-CLI/2.2.0", + "source": "block.js", + "domain": "messages", + "locale_data": { + "messages": { + "": { + "domain": "messages", + "lang": "eo", + "plural-forms": "nplurals=2; plural=(n != 1);" + }, + "Simple Block": [ + "Simpla Bloko" + ], + "Hello World": [ + "Salunton mondo" + ] + } + } +} +``` + + +### Load Translation File + +The final part is to tell WordPress where it can look to find the translation file. The `wp_set_script_translations` function accepts an optional third argument that is the path it will first check for translations. For example: + +```php + General and change your site language to Esperanto. + +With the language set, create a new post, add the block, and you will see the translations used. + diff --git a/gb-src/docs/designers-developers/developers/packages.md b/gb-src/docs/designers-developers/developers/packages.md new file mode 100644 index 0000000000000..fda7d986209ef --- /dev/null +++ b/gb-src/docs/designers-developers/developers/packages.md @@ -0,0 +1,40 @@ +# Package Reference + +WordPress exposes a list of JavaScript packages and tools for WordPress development. + +## Using the Packages via WordPress Global + +JavaScript packages are available as a registered script in WordPress and can be accessed using the `wp` global variable. + +If you wanted to use the `PlainText` component from the editor module, first you would specify `wp-editor` as a dependency when you enqueue your script: + +```php +wp_enqueue_script( + 'my-custom-block', + plugins_url( $block_path, __FILE__ ), + array( 'wp-blocks', 'wp-editor', 'wp-element', 'wp-i18n' ) +); +``` + +After the dependency is declared, you can access the module in your JavaScript code using the global `wp` like so: +```js +const { PlainText } = wp.editor; + +``` + +## Using the Packages via npm + +All the packages are also available on [npm](https://www.npmjs.com/org/wordpress) if you want to bundle them in your code. + +Using the same `PlainText` example, you would install the block editor module with npm: + +```bash +npm install @wordpress/block-editor --save +``` + +Once installed, you can access the component in your code using: + +```js +import { PlainText } from '@wordpress/block-editor'; +``` + diff --git a/gb-src/docs/designers-developers/developers/richtext.md b/gb-src/docs/designers-developers/developers/richtext.md new file mode 100644 index 0000000000000..e662ce41b7cf9 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/richtext.md @@ -0,0 +1,117 @@ +# RichText Reference + +RichText is a component that allows developers to render a [`contenteditable` input](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content), providing users with the option to format block content to make it bold, italics, linked, or use other formatting. + +The RichText component is extremely powerful because it provides built-in functionality you won't find in other components: + +* **Consistent Styling in the Admin and Frontend:** The editable container can be set to any block-level element, such as a `div`, `h2` or `p` tag. This allows the styles you apply in style.css to more easily apply on the frontend and admin, without having to rewrite them in editor.css. +* **Cohesive Placeholder Text:** Before the user writes their content, it's easy to include placeholder text that's already styled to match the rest of the block editor. +* **Control Over Formatting Options:** It's possible to dictate exactly which formatting options you want to allow for the RichText field. For example, you can dictate whether to allow the user to make text bold, italics or both. + +Unlike other components that exist in the [Component Reference](/packages/components/README.md) section, RichText lives separately because it only makes sense within the block editor, and not within other areas of WordPress. + +## Property Reference + +For a list of the possible properties to pass your RichText component, [check out the component documentation on Github](https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/rich-text/README.md). + +## Core Blocks Using the RichText Component + +There are a number of core blocks using the RichText component. The JavaScript edit function linked below for each block can be used as a best practice reference while creating your own blocks. + +* **[Button](https://github.com/WordPress/gutenberg/blob/master/packages/block-library/src/button/edit.js):** RichText is used to enter the button's text. +* **[Heading](https://github.com/WordPress/gutenberg/blob/master/packages/block-library/src/heading/edit.js):** RichText is used to enter the heading's text. +* **[Quote](https://github.com/WordPress/gutenberg/blob/master/packages/block-library/src/quote/edit.js):** RichText is used in two places, for both the quotation and citation text. +* **[Search](https://github.com/WordPress/gutenberg/blob/master/packages/block-library/src/search/edit.js):** RichText is used in two places, for both the label above the search field and the submit button text. + +## Example + +{% codetabs %} +{% ES5 %} +```js +wp.blocks.registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'h2', + }, + }, + + edit: function( props ) { + return wp.element.createElement( wp.editor.RichText, { + tagName: 'h2', // The tag here is the element output and editable in the admin + className: props.className, + value: props.attributes.content, // Any existing content, either from the database or an attribute default + formattingControls: [ 'bold', 'italic' ], // Allow the content to be made bold or italic, but do not allow other formatting options + onChange: function( content ) { + props.setAttributes( { content: content } ); // Store updated content as a block attribute + }, + placeholder: __( 'Heading...' ), // Display this text before any content has been added by the user + } ); + }, + + save: function( props ) { + return wp.element.createElement( wp.editor.RichText.Content, { + tagName: 'h2', value: props.attributes.content // Saves

Content added in the editor...

to the database for frontend display + } ); + } +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { RichText } = wp.editor; + +registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'h2', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + setAttributes( { content } ) } // Store updated content as a block attribute + placeholder={ __( 'Heading...' ) } // Display this text before any content has been added by the user + /> + ); + }, + + save( { attributes } ) { + return ; // Saves

Content added in the editor...

to the database for frontend display + } +} ); +``` +{% end %} + +## Common Issues & Solutions + +While using the RichText component a number of common issues tend to appear. + +### Placeholder Content Separates from the Input + +In some cases the placeholder content on RichText can appear separate from the input where you would write your content. This is likely due to one of two reasons: + +1. You can't use an [inline HTML element](https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements) as the RichText component. If your `tagName` property is using an inline element such as `span`, `a` or `code`, it needs to be changed to a [block-level element](https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements). +2. The `position` CSS property value for the element must be set to `relative` or `absolute` within the admin. If the styles within style.css or editor.css modify the `position` property value for this element, you may see issues with how it displays. + +### HTML Formatting Tags Display in the Content + +If the HTML tags from text formatting such as `` or `` are being escaped and displayed on the frontend of the site, this is likely due to an issue in your save function. Make sure your code looks something like `` (ESNext) within your save function instead of simply outputting the value with `

{ heading }

`. + +### Unwanted Formatting Options Still Display + +Before moving forward, consider if using the RichText component makes sense at all. Would it be better to use a basic `input` or `textarea` element? If you don't think any formatting should be possible, these HTML tags may make more sense. + +If you'd still like to use RichText, you can eliminate all of the formatting options by specifying the `formattingControls` property as `formattingControls={ [] }` (ESNext). It's possible you'll continue to see formatting options for adding code, an inline image or other formatting. Don't worry, you've found an existing bug that should be fixed soon. diff --git a/gb-src/docs/designers-developers/developers/slotfills/README.md b/gb-src/docs/designers-developers/developers/slotfills/README.md new file mode 100644 index 0000000000000..4ae945f3c4125 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/README.md @@ -0,0 +1,113 @@ +# SlotFills Reference + +Slot and Fill are components that have been exposed to allow developers to inject items into some predefined places in the Gutenberg admin experience. +Please see the [SlotFill component docs](https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/slot-fill/) for more details. + +In order to use them, we must leverage the [@wordpress/plugins](https://wordpress.org/gutenberg/handbook/designers-developers/developers/packages/packages-plugins/) api to register a plugin that will inject our items. + +## Usage overview + +In order to access the SlotFills, we need to do four things: + +1. Import the `registerPlugin` method from `wp.plugins`. +2. Import the SlotFill we want from `wp.editPost`. +3. Define a method to render our changes. Our changes/additions will be wrapped in the SlotFill component we imported. +4. Register the plugin. + + + +Here is an example using the `PluginPostStatusInfo` slotFill: +```js +const { registerPlugin } = wp.plugins; +const { PluginPostStatusInfo } = wp.editPost; + + +const PluginPostStatusInfoTest = () => { + return( + +

Post Status Info SlotFill

+
+ ) +} + +registerPlugin( 'post-status-info-test', { render: PluginPostStatusInfoTest } ); +``` + +## How do they work? + +SlotFills are created using `createSlotFill`. This creates two components, `Slot` and `Fill` which are then used to create a new component that is exported on the `wp.plugins` global. + +**Definition of the `PluginPostStatusInfo` SlotFill** ([see core code](https://github.com/WordPress/gutenberg/blob/master/packages/edit-post/src/components/sidebar/plugin-post-status-info/index.js#L54)) + +```js +/** + * Defines as extensibility slot for the Status & Visibility panel. + */ + +/** + * WordPress dependencies + */ +import { createSlotFill, PanelRow } from '@wordpress/components'; + +export const { Fill, Slot } = createSlotFill( 'PluginPostStatusInfo' ); + +const PluginPostStatusInfo = ( { children, className } ) => ( + + + { children } + + +); + +PluginPostStatusInfo.Slot = Slot; + +export default PluginPostStatusInfo; + +``` + +This new Slot is then exposed in the editor. The example below is from core and represents the Status & Visibility panel. + +As we can see, the `` is wrapping all of the items that will appear in the panel. +Any items that have been added via the SlotFill ( see the example above ), will be included in the `fills` parameter and be displayed between the `` and `` components. + +See [core code](https://github.com/WordPress/gutenberg/tree/master/packages/edit-post/src/components/sidebar/post-status/index.js#L26). + +```js +function PostStatus( { isOpened, onTogglePanel } ) { + return ( + + + { ( fills ) => ( + + + + + + + + { fills } + + + ) } + + + ); +} +``` + +## Currently available SlotFills and examples + +There are currently seven available SlotFills in the `edit-post` package. Please refer to the individual items below for usage and example details: + +* [PluginBlockSettingsMenuItem](/docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md) +* [PluginDocumentSettingPanel](/docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md) +* [PluginMoreMenuItem](/docs/designers-developers/developers/slotfills/plugin-more-menu-item.md) +* [PluginPostPublishPanel](/docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md) +* [PluginPostStatusInfo](/docs/designers-developers/developers/slotfills/plugin-post-status-info.md) +* [PluginPrePublishPanel](/docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md) +* [PluginSidebar](/docs/designers-developers/developers/slotfills/plugin-sidebar.md) +* [PluginSidebarMoreMenuItem](/docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md) + + + + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md new file mode 100644 index 0000000000000..ce81b96f3e182 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md @@ -0,0 +1,27 @@ +# PluginBlockSettingsMenuItem + +This slot allows for adding a new item into the More Options area. +This will either appear in the controls for each block or at the Top Toolbar depending on the users setting. + + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginBlockSettingsMenuItem } = wp.editPost; + +const PluginBlockSettingsMenuGroupTest = () => ( + { alert( 'clicked' )} } /> +) + +registerPlugin( 'block-settings-menu-group-test', { render: PluginBlockSettingsMenuGroupTest } ); +``` + +## Location + +![Location](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-block-settings-menu-item-screenshot.png?raw=true "PluginBlockSettingsMenuItem Location") + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md new file mode 100644 index 0000000000000..75f0a3638f1b4 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md @@ -0,0 +1,26 @@ +# PluginDocumentSettingPanel + +This SlotFill allows registering a UI to edit Document settings. + +## Available Props +* __name__ `string`: A string identifying the panel. +* __className__ `string`: An optional class name added to the sidebar body. +* __title__ `string`: Title displayed at the top of the sidebar. +* __icon__ `(string|Element)`: The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element. + +## Example +```js +const { registerPlugin } = wp.plugins; +const { PluginDocumentSettingPanel } = wp.editPost; + +const PluginDocumentSettingPanelDemo = () => ( + + Custom Panel Contents + +); +registerPlugin( 'plugin-document-setting-panel-demo', { render: PluginDocumentSettingPanelDemo, icon: 'palmtree' } ); +``` \ No newline at end of file diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-more-menu-item.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-more-menu-item.md new file mode 100644 index 0000000000000..23efd6a021685 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-more-menu-item.md @@ -0,0 +1,26 @@ +# PluginMoreMenuItem + +This slot will add a new item to the More Tools & Options section. + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginMoreMenuItem } = wp.editPost; + +const MyButtonMoreMenuItemTest = () => ( + { alert( 'Button Clicked' ) } } + > + More Menu Item + +); + +registerPlugin( 'more-menu-item-test', { render: MyButtonMoreMenuItemTest } ); +``` + +## Location + +![Location](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-more-menu-item.png?raw=true) + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md new file mode 100644 index 0000000000000..50c38098c1949 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md @@ -0,0 +1,26 @@ +# PluginPostPublishPanel + +This slot allows for injecting items into the bottom of the post-publish panel that appears after a post is published. + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginPostPublishPanel } = wp.editPost; + +const PluginPostPublishPanelTest = () => { + return ( + +

Post Publish Panel

+
+ ) +} + +registerPlugin( 'post-publish-panel-test', { render: PluginPostPublishPanelTest } ); + +``` + +## Location + +![post publish panel](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-post-publish-panel.png?raw=true) + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-post-status-info.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-post-status-info.md new file mode 100644 index 0000000000000..4e0ad584872b0 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-post-status-info.md @@ -0,0 +1,26 @@ +# PluginPostStatusInfo + +This slots allows for the insertion of items in the Status & Visibility panel of the document sidebar. + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginPostStatusInfo } = wp.editPost; + + +const PluginPostStatusInfoTest = () => { + return( + +

Post Status Info SlotFill

+
+ ) +} + +registerPlugin( 'post-status-info-test', { render: PluginPostStatusInfoTest } ); +``` + +## Location + +![Location in the Status & Visibility panel](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-post-status-info-location.png?raw=true) + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md new file mode 100644 index 0000000000000..622a945dd08d8 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md @@ -0,0 +1,26 @@ +# PluginPrePublishPanel + +This slot allows for injecting items into the bottom of the pre-publish panel that appears to confirm publishing after the user clicks "Publish'. + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginPrePublishPanel }= wp.editPost; + +const PluginPrePublishPanelTest = () => { + return ( + +

Pre Publish Panel

+
+ ) +} + +registerPlugin( 'pre-publish-panel-test', { render: PluginPrePublishPanelTest } ); + +``` + +## Location + +![Prepublish panel](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-pre-publish-panel.png?raw=true) + diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md new file mode 100644 index 0000000000000..5bd43819501e7 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md @@ -0,0 +1,41 @@ +# PluginSidebarMoreMenuItem + +This slot allows the creation of a `` with a menu item that when clicked will expand the sidebar to the appropriate Plugin section. +This is done by setting the `target` on `` to match the `name` on the `` + + +## Example + +```js +const { registerPlugin } = wp.plugins; + +const { + PluginSidebar, + PluginSidebarMoreMenuItem +} = wp.editPost; + +const { Fragment } = wp.element; + +const PluginSidebarMoreMenuItemTest = () => ( + + + Expanded Sidebar - More item + + + Content of the sidebar + + +) + +registerPlugin( 'plugin-sidebar-expanded-test', { render: PluginSidebarMoreMenuItemTest } ); +``` + +## Location + +![Interaction](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-sidebar-more-menu-item.gif?raw=true) diff --git a/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar.md b/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar.md new file mode 100644 index 0000000000000..66121d8c8941c --- /dev/null +++ b/gb-src/docs/designers-developers/developers/slotfills/plugin-sidebar.md @@ -0,0 +1,35 @@ +# PluginSidebar + +This slot allows for adding items into the Gutenberg Toolbar. +Using this slot will add an icon to the bar that, when clicked, will open a sidebar with the content of the items wrapped in the `` component. + +## Example + +```js +const { registerPlugin } = wp.plugins; +const { PluginSidebar } = wp.editPost; + +const PluginSidebarTest = () => { + return( + +

Plugin Sidebar

+
+ ) +} +registerPlugin( 'plugin-sidebar-test', { render: PluginSidebarTest } ); + +``` + +## Location + +### Closed State + +![Closed State](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/plugin-sidebar-closed-state.png?raw=true) + +### Open State + +![Open State](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers//assets/plugin-sidebar-open-state.png?raw=true) diff --git a/gb-src/docs/designers-developers/developers/themes/README.md b/gb-src/docs/designers-developers/developers/themes/README.md new file mode 100644 index 0000000000000..c36b28093d015 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/themes/README.md @@ -0,0 +1,5 @@ +# Theming for the Block Editor + +The new editor provides a number of options for theme designers and developers, including theme-defined color settings, font size control, and much more. + +In this section, you'll learn about the ways that themes can customise the editor. diff --git a/gb-src/docs/designers-developers/developers/themes/theme-support.md b/gb-src/docs/designers-developers/developers/themes/theme-support.md new file mode 100644 index 0000000000000..210072f5d2cf0 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/themes/theme-support.md @@ -0,0 +1,295 @@ +# Theme Support + +The new Blocks include baseline support in all themes, enhancements to opt-in to and the ability to extend and customize. + +There are a few new concepts to consider when building themes: + +- **Editor Color Palette** - A default set of colors is provided, but themes can register their own and optionally lock users into picking from the defined palette. +- **Editor Text Size Palette** - A default set of sizes is provided, but themes can register their own and optionally lock users into picking from preselected sizes. +- **Responsive Embeds** - Themes must opt-in to responsive embeds. +- **Frontend & Editor Styles** - To get the most out of blocks, theme authors will want to make sure Core styles look good and opt-in, or write their own styles to best fit their theme. +- **Dark Mode** - If a Theme is a Dark Theme with a dark background containing light text, the theme author can opt-in to the Dark Mode. + +By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or they can provide no styles at all, and rely fully on what the blocks provide. + +Some advanced block features require opt-in support in the theme itself as it's difficult for the block to provide these styles, they may require some architecting of the theme itself, in order to work well. + +To opt-in for one of these features, call `add_theme_support` in the `functions.php` file of the theme. For example: + +```php +function mytheme_setup_theme_supported_features() { + add_theme_support( 'editor-color-palette', array( + array( + 'name' => __( 'strong magenta', 'themeLangDomain' ), + 'slug' => 'strong-magenta', + 'color' => '#a156b4', + ), + array( + 'name' => __( 'light grayish magenta', 'themeLangDomain' ), + 'slug' => 'light-grayish-magenta', + 'color' => '#d0a5db', + ), + array( + 'name' => __( 'very light gray', 'themeLangDomain' ), + 'slug' => 'very-light-gray', + 'color' => '#eee', + ), + array( + 'name' => __( 'very dark gray', 'themeLangDomain' ), + 'slug' => 'very-dark-gray', + 'color' => '#444', + ), + ) ); +} + +add_action( 'after_setup_theme', 'mytheme_setup_theme_supported_features' ); +``` + +## Opt-in features + +### Wide Alignment: + +Some blocks such as the image block have the possibility to define a "wide" or "full" alignment by adding the corresponding classname to the block's wrapper ( `alignwide` or `alignfull` ). A theme can opt-in for this feature by calling: + +```php +add_theme_support( 'align-wide' ); +``` + +For more information about this function, see [the developer docs on `add_theme_support()`](https://developer.wordpress.org/reference/functions/add_theme_support/). + +### Wide Alignments and Floats + +It can be difficult to create a responsive layout that accommodates wide images, a sidebar, a centered column, and floated elements that stay within that centered column. + +The block editor adds additional markup to floated images to make styling them easier. + +Here's the markup for an `Image` with a caption: + +```html +
+ +
Short image caption.
+
+``` + +Here's the markup for a left-floated image: + +```html +
+
+ +
Short image caption.
+
+
+``` + +Here's an example using the above markup to achieve a responsive layout that features a sidebar, wide images, and floated elements with bounded captions: https://codepen.io/joen/pen/zLWvrW. + +### Block Color Palettes + +Different blocks have the possibility of customizing colors. The block editor provides a default palette, but a theme can overwrite it and provide its own: + +```php +add_theme_support( 'editor-color-palette', array( + array( + 'name' => __( 'strong magenta', 'themeLangDomain' ), + 'slug' => 'strong-magenta', + 'color' => '#a156b4', + ), + array( + 'name' => __( 'light grayish magenta', 'themeLangDomain' ), + 'slug' => 'light-grayish-magenta', + 'color' => '#d0a5db', + ), + array( + 'name' => __( 'very light gray', 'themeLangDomain' ), + 'slug' => 'very-light-gray', + 'color' => '#eee', + ), + array( + 'name' => __( 'very dark gray', 'themeLangDomain' ), + 'slug' => 'very-dark-gray', + 'color' => '#444', + ), +) ); +``` + +`name` is a human-readable label (demonstrated above) that appears in the tooltip and provides a meaningful description of the color to users. It is especially important for those who rely on screen readers or would otherwise have difficulty perceiving the color. `slug` is a unique identifier for the color and is used to generate the CSS classes used by the block editor color palette. `color` is the hexadecimal code to specify the color. + +Some colors change dynamically — such as "Primary" and "Secondary" color — such as in the Twenty Nineteen theme and cannot be described programmatically. In spite of that, it is still advisable to provide meaningful `name`s for at least the default values when applicable. + +The colors will be shown in order on the palette, and there's no limit to how many can be specified. + +Themes are responsible for creating the classes that apply the colors in different contexts. Core blocks use "color" and "background-color" contexts. So to correctly apply "strong magenta" to all contexts of core blocks a theme should implement the following classes: + +```css +.has-strong-magenta-background-color { + background-color: #313131; +} + +.has-strong-magenta-color { + color: #f78da7; +} +``` + +The class name is built appending 'has-', followed by the class name _using_ kebab case and ending with the context name. + +### Block Font Sizes: + +Blocks may allow the user to configure the font sizes they use, e.g., the paragraph block. The block provides a default set of font sizes, but a theme can overwrite it and provide its own: + +```php +add_theme_support( 'editor-font-sizes', array( + array( + 'name' => __( 'Small', 'themeLangDomain' ), + 'size' => 12, + 'slug' => 'small' + ), + array( + 'name' => __( 'Normal', 'themeLangDomain' ), + 'size' => 16, + 'slug' => 'normal' + ), + array( + 'name' => __( 'Large', 'themeLangDomain' ), + 'size' => 36, + 'slug' => 'large' + ), + array( + 'name' => __( 'Huge', 'themeLangDomain' ), + 'size' => 50, + 'slug' => 'huge' + ) +) ); +``` + +The font sizes are rendered on the font size picker in the order themes provide them. + +Themes are responsible for creating the classes that apply the correct font size styles. +The class name is built appending 'has-', followed by the font size name _using_ kebab case and ending with `-font-size`. + +As an example for the regular font size, a theme may provide the following class. + +```css +.has-regular-font-size { + font-size: 16px; +} +``` + +### Disabling custom font sizes + +Themes can disable the ability to set custom font sizes with the following code: + +```php +add_theme_support('disable-custom-font-sizes'); +``` + +When set, users will be restricted to the default sizes provided in the block editor or the sizes provided via the `editor-font-sizes` theme support setting. + +### Disabling custom colors in block Color Palettes + +By default, the color palette offered to blocks allows the user to select a custom color different from the editor or theme default colors. + +Themes can disable this feature using: + +```php +add_theme_support( 'disable-custom-colors' ); +``` + +This flag will make sure users are only able to choose colors from the `editor-color-palette` the theme provided or from the editor default colors if the theme did not provide one. + +## Editor styles + +The block editor supports the theme's [editor styles](https://codex.wordpress.org/Editor_Style), however it works a little differently than in the classic editor. + +In the classic editor, the editor stylesheet is loaded directly into the iframe of the WYSIWYG editor, with no changes. The block editor, however, doesn't use iframes. To make sure your styles are applied only to the content of the editor, we automatically transform your editor styles by selectively rewriting or adjusting certain CSS selectors. This also allows the block editor to leverage your editor style in block variation previews. + +For example, if you write `body { ... }` in your editor style, this is rewritten to `.editor-styles-wrapper { ... }`. This also means that you should _not_ target any of the editor class names directly. + +Because it works a little differently, you need to opt-in to this by adding an extra snippet to your theme, in addition to the add_editor_style function: + +```php +add_theme_support('editor-styles'); +``` + +You shouldn't need to change your editor styles too much; most themes can add the snippet above and get similar results in the classic editor and inside the block editor. + +### Dark backgrounds + +If your editor style relies on a dark background, you can add the following to adjust the color of the UI to work on dark backgrounds: + +```php +add_theme_support( 'editor-styles' ); +add_theme_support( 'dark-editor-style' ); +``` + +Note you don't need to add `add_theme_support( 'editor-styles' );` twice, but that rule does need to be present for the `dark-editor-style` rule to work. + +### Enqueuing the editor style + +Use the `add_editor_style` function to enqueue and load CSS on the editor screen. For the classic editor, this was the only function needed to add style to the editor. For the new block editor, you first need to `add_theme_support( 'editor-styles');` mentioned above. + +```php +add_editor_style( 'style-editor.css' ); +``` + +Adding that to your `functions.php` file will add the stylesheet `style-editor.css` to the queue of stylesheets to be loaded in the editor. + +### Basic colors + +You can style the editor like any other webpage. Here's how to change the background color and the font color to blue: + +```css +/* Add this to your `style-editor.css` file */ +body { + background-color: #d3ebf3; + color: #00005d; +} +``` + +### Changing the width of the editor + +To change the main column width of the editor, add the following CSS to `style-editor.css`: + +```css +/* Main column width */ +.wp-block { + max-width: 720px; +} + +/* Width of "wide" blocks */ +.wp-block[data-align="wide"] { + max-width: 1080px; +} + +/* Width of "full-wide" blocks */ +.wp-block[data-align="full"] { + max-width: none; +} +``` + +You can use those editor widths to match those in your theme. You can use any CSS width unit, including `%` or `px`. + +Further reading: [Applying Styles with Stylesheets](/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md). + +## Default block styles + +Core blocks include default styles. The styles are enqueued for editing but are not enqueued for viewing unless the theme opts-in to the core styles. If you'd like to use default styles in your theme, add theme support for `wp-block-styles`: + +```php +add_theme_support( 'wp-block-styles' ); +``` + +## Responsive embedded content + +The embed blocks automatically apply styles to embedded content to reflect the aspect ratio of content that is embedded in an iFrame. A block styled with the aspect ratio responsive styles would look like: + +```html +
...
+``` + +To make the content resize and keep its aspect ratio, the `` element needs the `wp-embed-responsive` class. This is not set by default, and requires the theme to opt in to the `responsive-embeds` feature: + +```php +add_theme_support( 'responsive-embeds' ); +``` diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md new file mode 100644 index 0000000000000..76100adfcd404 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md @@ -0,0 +1,126 @@ +# Applying Styles From a Stylesheet + +In the previous step, the block had applied its own styles by an inline `style` attribute. While this might be adequate for very simple components, you will quickly find that it becomes easier to write your styles by extracting them to a separate stylesheet file. + +The editor will automatically generate a class name for each block type to simplify styling. It can be accessed from the object argument passed to the edit and save functions. In step 2, we will create a stylesheet to use that class name. + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, element ) { + var el = element.createElement; + + blocks.registerBlockType( 'gutenberg-examples/example-02-stylesheets', { + title: 'Example: Stylesheets', + icon: 'universal-access-alt', + category: 'layout', + edit: function( props ) { + return el( + 'p', + { className: props.className }, + 'Hello World, step 2 (from the editor, in green).' + ); + }, + save: function() { + return el( + 'p', + {}, + 'Hello World, step 2 (from the frontend, in red).' + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; + +registerBlockType( 'gutenberg-examples/example-02-stylesheets', { + title: 'Example: Stylesheets', + + icon: 'universal-access-alt', + + category: 'layout', + + edit( { className } ) { + return

Hello World, step 2 (from the editor, in green).

; + }, + + save() { + return

Hello World, step 2 (from the frontend, in red).

; + } +} ); +``` +{% end %} + +The class name is generated using the block's name prefixed with `wp-block-`, replacing the `/` namespace separator with a single `-`. + +## Enqueueing Editor and Front end Assets + +Like scripts, you need to enqueue your block's styles. As explained in the section before, you use the `editor_style` handle for styles only relevant in the editor, and the `style` handle for common styles applied both in the editor and the front of your site. + +The stylesheets enqueued by `style` are the base styles and are loaded first. The `editor` stylesheet will be loaded after it. + +Let's move on into code. Create a file called `editor.css`: + +```css +.wp-block-gutenberg-examples-example-02-stylesheets { + color: green; + background: #cfc; + border: 2px solid #9c9; + padding: 20px; +} +``` + +And a new `style.css` file containing: + +```css +.wp-block-gutenberg-examples-example-02-stylesheets { + color: darkred; + background: #fcc; + border: 2px solid #c99; + padding: 20px; +} +``` + +Configure your plugin to use these new styles: + +```php + 'gutenberg-examples-02', + 'editor_style' => 'gutenberg-examples-02-editor', + 'editor_script' => 'gutenberg-examples-02', + ) ); +} +add_action( 'init', 'gutenberg_examples_02_register_block' ); +``` diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md new file mode 100644 index 0000000000000..5e120d684f035 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md @@ -0,0 +1,178 @@ +# Block Controls: Block Toolbar and Settings Sidebar + +To simplify block customization and ensure a consistent experience for users, there are a number of built-in UI patterns to help generate the editor preview. Like with the `RichText` component covered in the previous chapter, the `wp.editor` global includes a few other common components to render editing interfaces. In this chapter, we'll explore toolbars and the block inspector. + +## Block Toolbar + +![Screenshot of the rich text toolbar applied to a Paragraph block inside the block editor](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/toolbar-text.png) + +When the user selects a block, a number of control buttons may be shown in a toolbar above the selected block. Some of these block-level controls are included automatically if the editor is able to transform the block to another type, or if the focused element is a RichText component. + +You can also customize the toolbar to include controls specific to your block type. If the return value of your block type's `edit` function includes a `BlockControls` element, those controls will be shown in the selected block's toolbar. + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, editor, element ) { + var el = element.createElement; + var RichText = editor.RichText; + var AlignmentToolbar = editor.AlignmentToolbar; + var BlockControls = editor.BlockControls; + + blocks.registerBlockType( 'gutenberg-examples/example-04-controls', { + title: 'Example: Controls', + icon: 'universal-access-alt', + category: 'layout', + + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + alignment: { + type: 'string', + default: 'none', + }, + }, + + edit: function( props ) { + var content = props.attributes.content; + var alignment = props.attributes.alignment; + + function onChangeContent( newContent ) { + props.setAttributes( { content: newContent } ); + } + + function onChangeAlignment( newAlignment ) { + props.setAttributes( { alignment: newAlignment === undefined ? 'none' : newAlignment } ); + } + + return [ + el( + BlockControls, + { key: 'controls' }, + el( + AlignmentToolbar, + { + value: alignment, + onChange: onChangeAlignment, + } + ) + ), + el( + RichText, + { + key: 'richtext', + tagName: 'p', + style: { textAlign: alignment }, + className: props.className, + onChange: onChangeContent, + value: content, + } + ), + ]; + }, + + save: function( props ) { + return el( RichText.Content, { + tagName: 'p', + className: 'gutenberg-examples-align-' + props.attributes.alignment, + value: props.attributes.content, + } ); + }, + } ); +}( + window.wp.blocks, + window.wp.editor, + window.wp.element +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; + +const { + RichText, + AlignmentToolbar, + BlockControls, +} = wp.editor; + +registerBlockType( 'gutenberg-examples/example-04-controls-esnext', { + title: 'Example: Controls (esnext)', + icon: 'universal-access-alt', + category: 'layout', + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + alignment: { + type: 'string', + default: 'none', + }, + }, + edit: ( props ) => { + const { + attributes: { + content, + alignment, + }, + className, + } = props; + + const onChangeContent = ( newContent ) => { + props.setAttributes( { content: newContent } ); + }; + + const onChangeAlignment = ( newAlignment ) => { + props.setAttributes( { alignment: newAlignment === undefined ? 'none' : newAlignment } ); + }; + + return ( +
+ { + + + + } + +
+ ); + }, + save: ( props ) => { + return ( + + ); + }, +} ); +``` +{% end %} + +Note that `BlockControls` is only visible when the block is currently selected and in visual editing mode. `BlockControls` are not shown when editing a block in HTML editing mode. + +## Inspector + +![Screenshot of the inspector panel focused on the settings for a Paragraph block](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/inspector.png) + +The Settings Sidebar is used to display less-often-used settings or settings that require more screen space. The Settings Sidebar should be used for **block-level settings only**. + +If you have settings that affects only selected content inside a block (example: the "bold" setting for selected text inside a paragraph): **do not place it inside the Settings Sidebar**. The Settings Sidebar is displayed even when editing a block in HTML mode, so it should only contain block-level settings. + +The Block Tab is shown in place of the Document Tab when a block is selected. + +Similar to rendering a toolbar, if you include an `InspectorControls` element in the return value of your block type's `edit` function, those controls will be shown in the Settings Sidebar region. diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md new file mode 100644 index 0000000000000..27dd92526d3e3 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md @@ -0,0 +1,204 @@ +# Creating dynamic blocks + +Dynamic blocks are blocks that build their structure and content on the fly when the block is rendered on the front end. + +There are two primary uses for dynamic blocks: + +1. Blocks where content should change even if a post has not been updated. One example from WordPress itself is the Latest Posts block. This block will update everywhere it is used when a new post is published. +2. Blocks where updates to the code (HTML, CSS, JS) should be immediately shown on the front end of the website. For example, if you update the structure of a block by adding a new class, adding an HTML element, or changing the layout in any other way, using a dynamic block ensures those changes are applied immediately on all occurrences of that block across the site. (If a dynamic block is not used then when block code is updated Guterberg's [validation process](https://developer.wordpress.org/block-editor/developers/block-api/block-edit-save/#validation) generally applies, causing users to see the validation message, "This block appears to have been modified externally"). + +For many dynamic blocks, the `save` callback function should be returned as `null`, which tells the editor to save only the [block attributes](https://developer.wordpress.org/block-editor/developers/block-api/block-attributes/) to the database. These attributes are then passed into the server-side rendering callback, so you can decide how to display the block on the front end of your site. When you return `null`, the editor will skip the block markup validation process, avoiding issues with frequently-changing markup. + +If you are using [InnerBlocks](https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inner-blocks/README.md) in a dynamic block you will need to save the `InnerBlocks` in the `save` callback function using `` + +You can also save an HTML representation of the block. If you provide a server-side rendering callback, this HTML will be replaced with the output of your callback, but will be rendered if your block is deactivated or your render callback is removed. + +Block attributes can be used for any content or setting you want to save for that block. In the first example above, with the latest posts block, the number of latest posts you want to show could be saved as an attribute. Or in the second example, attributes can be used for each piece of content you want to show in the front end - such as heading text, paragraph text, an image, a URL, etc. + +The following code example shows how to create a dynamic block that shows only the last post as a link. + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, element, data ) { + + var el = element.createElement, + registerBlockType = blocks.registerBlockType, + withSelect = data.withSelect; + + registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', + + edit: withSelect( function( select ) { + return { + posts: select( 'core' ).getEntityRecords( 'postType', 'post' ) + }; + } )( function( props ) { + + if ( ! props.posts ) { + return "Loading..."; + } + + if ( props.posts.length === 0 ) { + return "No posts"; + } + var className = props.className; + var post = props.posts[ 0 ]; + + return el( + 'a', + { className: className, href: post.link }, + post.title.rendered + ); + } ), + } ); +}( + window.wp.blocks, + window.wp.element, + window.wp.data, +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { withSelect } = wp.data; + +registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', + + edit: withSelect( ( select ) => { + return { + posts: select( 'core' ).getEntityRecords( 'postType', 'post' ) + }; + } )( ( { posts, className } ) => { + + if ( ! posts ) { + return "Loading..."; + } + + if ( posts && posts.length === 0 ) { + return "No posts"; + } + + let post = posts[ 0 ]; + + return + { post.title.rendered } + ; + } ), +} ); +``` +{% end %} + +Because it is a dynamic block it doesn't need to override the default `save` implementation on the client. Instead, it needs a server component. The contents in the front of your site depend on the function called by the `render_callback` property of `register_block_type`. + +```php + 1, + 'post_status' => 'publish', + ) ); + if ( count( $recent_posts ) === 0 ) { + return 'No posts'; + } + $post = $recent_posts[ 0 ]; + $post_id = $post['ID']; + return sprintf( + '%2$s', + esc_url( get_permalink( $post_id ) ), + esc_html( get_the_title( $post_id ) ) + ); +} + +function gutenberg_examples_05_dynamic() { + wp_register_script( + 'gutenberg-examples-05', + plugins_url( 'block.js', __FILE__ ), + array( 'wp-blocks', 'wp-element', 'wp-data' ) + ); + + register_block_type( 'gutenberg-examples/example-05-dynamic', array( + 'editor_script' => 'gutenberg-examples-05', + 'render_callback' => 'gutenberg_examples_05_dynamic_render_callback' + ) ); + +} +add_action( 'init', 'gutenberg_examples_05_dynamic' ); + +``` + +There are a few things to notice: + +* The `edit` function still shows a representation of the block in the editor's context (this could be very different from the rendered version, it's up to the block's author) +* The built-in `save` function just returns `null` because the rendering is performed server-side. +* The server-side rendering is a function taking the block attributes and the block inner content as arguments, and returning the markup (quite similar to shortcodes) + +## Live rendering in the block editor + +Gutenberg 2.8 added the [``](/packages/components/src/server-side-render) block which enables rendering to take place on the server using PHP rather than in JavaScript. + +*Server-side render is meant as a fallback; client-side rendering in JavaScript is always preferred (client rendering is faster and allows better editor manipulation).* + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, element, components ) { + + var el = element.createElement, + registerBlockType = blocks.registerBlockType, + ServerSideRender = components.ServerSideRender; + + registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', + + edit: function( props ) { + + return ( + el(ServerSideRender, { + block: "gutenberg-examples/example-05-dynamic", + attributes: props.attributes + } ) + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element, + window.wp.components, +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { ServerSideRender } = wp.components; + +registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', + + edit: function( props ) { + return ( + + ); + }, +} ); +``` +{% end %} + +Note that this code uses the `wp.components` utility but not `wp.data`. Make sure to update the `wp-data` dependency to `wp-components` in the PHP code. diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md new file mode 100644 index 0000000000000..6108ec88b9da8 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md @@ -0,0 +1,235 @@ +# Generate Blocks with WP-CLI + +It turns out that writing the simplest possible block which contains only static content might not be the easiest task. It requires to follow closely the steps described in the documentation. It stems from the fact that you need to create at least 2 files and integrate your code with the existing APIs. One way to mitigate this inconvenience is to copy the source code of the existing block from one of the repositories that share working examples: +- [WordPress/gutenberg-examples](https://github.com/WordPress/gutenberg-examples) - the official examples for extending Gutenberg with plugins which create blocks +- [zgordon/gutenberg-course](https://github.com/zgordon/gutenberg-course) - a repository for Zac Gordon's Gutenberg Development Course +- [ahmadawais/create-guten-block](https://github.com/ahmadawais/create-guten-block) - A zero-configuration developer toolkit for building WordPress Gutenberg block plugins + +It might be also a good idea to browse the folder with [all core blocks](/packages/block-library/src) to see how they are implemented. + +## WP-CLI + +Another way of making a developer's life easier is to use [WP-CLI](http://wp-cli.org/), which provides a command-line interface for many actions you might perform on the WordPress instance. One of the commands generates all the code required to register a Gutenberg block for a plugin or theme. + +### Installing + +Before installing `WP-CLI`, please make sure your environment meets the minimum requirements: + +* UNIX-like environment (OS X, Linux, FreeBSD, Cygwin); limited support in Windows environment +* PHP 5.3.29 or later +* WordPress 3.7 or later + +Once you’ve verified requirements, you should follow the [installation instructions](http://wp-cli.org/#installing). Downloading the Phar file is the recommended installation method for most users. Should you need, see also the documentation on [alternative installation methods](https://make.wordpress.org/cli/handbook/installing/). + +### Using `wp scaffold block` + +The following command generates PHP, JS and CSS code for registering a block. + +```bash +wp scaffold block [--title=] [--dashicon=<dashicon>] [--category=<category>] [--theme] [--plugin=<plugin>] [--force] +``` + +Please refer to the [command documentation](https://github.com/wp-cli/scaffold-command#wp-scaffold-block) to learn more about the available options for the block. + +When you scaffold a block you must provide at least a `slug` name and either the `theme` or `plugin` name. In most cases, we recommended pairing blocks with plugins rather than themes, because only using plugin ensures that all blocks will still work when your theme changes. + +### Examples + +Here are some examples using `wp scaffold block` in action. + +#### Create a block inside the plugin + +The following command generates a `movie` block with the `My movie block` title for the existing plugin named `movies`: + +```bash +$ wp scaffold block movie --title="My movie block" --plugin=movies +Success: Created block 'My movie block'. +``` + +This will generate 4 files inside the `movies` plugin directory. All files contain inline documentation that will help to apply any further customizations to the block. It's worth mentioning that it is currently possible to scaffold only blocks containing static content and JavaScript code is written using ECMAScript 5 (ES5) standard which works with all modern browsers. + +`movies/blocks/movie.php` - you will have to manually include this file in your main plugin file: +```php +<?php +/** + * Functions to register client-side assets (scripts and stylesheets) for the + * Gutenberg block. + * + * @package movies + */ + +/** + * Registers all block assets so that they can be enqueued through Gutenberg in + * the corresponding context. + * + * @see https://developer.wordpress.org/block-editor/tutorials/block-tutorial/writing-your-first-block-type/ + */ +function movie_block_init() { + $dir = dirname( __FILE__ ); + + $block_js = 'movie/block.js'; + wp_register_script( + 'movie-block-editor', + plugins_url( $block_js, __FILE__ ), + array( + 'wp-blocks', + 'wp-i18n', + 'wp-element', + ), + filemtime( "$dir/$block_js" ) + ); + + $editor_css = 'movie/editor.css'; + wp_register_style( + 'movie-block-editor', + plugins_url( $editor_css, __FILE__ ), + array(), + filemtime( "$dir/$editor_css" ) + ); + + $style_css = 'movie/style.css'; + wp_register_style( + 'movie-block', + plugins_url( $style_css, __FILE__ ), + array(), + filemtime( "$dir/$style_css" ) + ); + + register_block_type( 'movies/movie', array( + 'editor_script' => 'movie-block-editor', + 'editor_style' => 'movie-block-editor', + 'style' => 'movie-block', + ) ); +} +add_action( 'init', 'movie_block_init' ); +``` + +`movies/blocks/movie/block.js`: +```js +( function( wp ) { + /** + * Registers a new block provided a unique name and an object defining its behavior. + * @see https://developer.wordpress.org/block-editor/developers/block-api/block-registration/ + */ + var registerBlockType = wp.blocks.registerBlockType; + /** + * Returns a new element of given type. Element is an abstraction layer atop React. + * @see https://developer.wordpress.org/block-editor/packages/packages-element/ + */ + var el = wp.element.createElement; + /** + * Retrieves the translation of text. + * @see https://developer.wordpress.org/block-editor/packages/packages-i18n/ + */ + var __ = wp.i18n.__; + + /** + * Every block starts by registering a new block type definition. + * @see https://developer.wordpress.org/block-editor/developers/block-api/block-registration/ + */ + registerBlockType( 'movies/movie', { + /** + * This is the display title for your block, which can be translated with `i18n` functions. + * The block inserter will show this name. + */ + title: __( 'My movie block' ), + + /** + * Blocks are grouped into categories to help users browse and discover them. + * The categories provided by core are `common`, `embed`, `formatting`, `layout` and `widgets`. + */ + category: 'widgets', + + /** + * Optional block extended support features. + */ + supports: { + // Removes support for an HTML mode. + html: false, + }, + + /** + * The edit function describes the structure of your block in the context of the editor. + * This represents what the editor will render when the block is used. + * @see https://developer.wordpress.org/block-editor/developers/block-api/block-edit-save/ + * + * @param {Object} [props] Properties passed from the editor. + * @return {Element} Element to render. + */ + edit: function( props ) { + return el( + 'p', + { className: props.className }, + __( 'Hello from the editor!' ) + ); + }, + + /** + * The save function defines the way in which the different attributes should be combined + * into the final markup, which is then serialized by Gutenberg into `post_content`. + * @see https://developer.wordpress.org/block-editor/developers/block-api/block-edit-save/#save + * + * @return {Element} Element to render. + */ + save: function() { + return el( + 'p', + {}, + __( 'Hello from the saved content!' ) + ); + } + } ); +} )( + window.wp +); +``` + +`movies/blocks/movie/editor.css`: +```css +/** + * The following styles get applied inside the editor only. + * + * Replace them with your own styles or remove the file completely. + */ + +.wp-block-movies-movie { + border: 1px dotted #f00; +} +``` + +`movies/blocks/movie/style.css`: +```css +/** + * The following styles get applied both on the front of your site and in the editor. + * + * Replace them with your own styles or remove the file completely. + */ + +.wp-block-movies-movie { + background-color: #000; + color: #fff; + padding: 2px; +} +``` + +#### Create a block inside the theme + +It is also possible to scaffold the same `movie` block and include it into the existing theme, e.g. `simple-life`: + +```bash +$ wp scaffold block movie --theme=simple-life + Success: Created block 'Movie block'. +``` + +#### Create a plugin and add two blocks + +If you don't have an existing plugin you can create a new one and add two blocks with `WP-CLI` as follows: + +```bash +# Create plugin called books +$ wp scaffold plugin books +# Add a block called book to plugin books +$ wp scaffold block book --title="Book" --plugin=books +# Add a second block to plugin called books. +$ wp scaffold block books --title="Book List" --plugin=books +``` diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md new file mode 100644 index 0000000000000..738ed6640ce55 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md @@ -0,0 +1,140 @@ +# Introducing Attributes and Editable Fields + +The example blocks so far are still not very interesting because they lack options to customize the appearance of the message. In this section, we will implement a RichText field allowing the user to specify their own message. Before doing so, it's important to understand how the state of a block (its "attributes") is maintained and changed over time. + +## Attributes + +Until now, the `edit` and `save` functions have returned a simple representation of a paragraph element. We also learned how these functions are responsible for _describing_ the structure of the block's appearance. If the user changes a block, this structure may need to change. To achieve this, the state of a block is maintained throughout the editing session as a plain JavaScript object, and when an update occurs, the `edit` function is invoked again. Put another way: __the output of a block is a function of its attributes__. + +One challenge of maintaining the representation of a block as a JavaScript object is that we must be able to extract this object again from the saved content of a post. This is achieved with the block type's `attributes` property: + +```js + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + }, +``` + +When registering a new block type, the `attributes` property describes the shape of the attributes object you'd like to receive in the `edit` and `save` functions. Each value is a [source function](/docs/designers-developers/developers/block-api/block-attributes.md) to find the desired value from the markup of the block. + +In the code snippet above, when loading the editor, the `content` value will be extracted from the HTML of the paragraph element in the saved post's markup. + +## Components and the `RichText` Component + +Earlier examples used the `createElement` function to create DOM nodes, but it's also possible to encapsulate this behavior into "components". This abstraction helps you share common behaviors and hide complexity in self-contained units. + +There are a number of [components available](/docs/designers-developers/developers/packages/packages-editor.md#components) to use in implementing your blocks. You can see one such component in the code below: the [`RichText` component](/docs/designers-developers/developers/packages/packages-editor.md#richtext). + +The `RichText` component can be considered as a super-powered `textarea` element, enabling rich content editing including bold, italics, hyperlinks, etc. + +To use the `RichText` component, add `wp-editor` to the dependency array of registered script handles when calling `wp_register_script`. + +```php +wp_register_script( + 'gutenberg-examples-03', + plugins_url( 'block.js', __FILE__ ), + array( + 'wp-blocks', + 'wp-element', + 'wp-editor' // Note the addition of wp-editor to the dependencies + ), + filemtime( plugin_dir_path( __FILE__ ) . 'block.js' ) +); +``` + +Do not forget to also update the `editor_script` handle in `register_block_type` to `gutenberg-examples-03`. + +Implementing this behavior as a component enables you as the block implementer to be much more granular about editable fields. Your block may not need `RichText` at all, or it may need many independent `RichText` elements, each operating on a subset of the overall block state. + +Because `RichText` allows for nested nodes, you'll most often use it in conjunction with the `html` attribute source when extracting the value from saved content. You'll also use `RichText.Content` in the `save` function to output RichText values. + +Here is the complete block definition for Example 03. + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, editor, element ) { + var el = element.createElement; + var RichText = editor.RichText; + + blocks.registerBlockType( 'gutenberg-examples/example-03-editable', { + title: 'Example: Editable', + icon: 'universal-access-alt', + category: 'layout', + + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + }, + + edit: function( props ) { + var content = props.attributes.content; + function onChangeContent( newContent ) { + props.setAttributes( { content: newContent } ); + } + + return el( + RichText, + { + tagName: 'p', + className: props.className, + onChange: onChangeContent, + value: content, + } + ); + }, + + save: function( props ) { + return el( RichText.Content, { + tagName: 'p', value: props.attributes.content, + } ); + }, + } ); +}( + window.wp.blocks, + window.wp.editor, + window.wp.element +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { RichText } = wp.editor; + +registerBlockType( 'gutenberg-examples/example-03-editable-esnext', { + title: 'Example: Editable (esnext)', + icon: 'universal-access-alt', + category: 'layout', + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + }, + edit: ( props ) => { + const { attributes: { content }, setAttributes, className } = props; + const onChangeContent = ( newContent ) => { + setAttributes( { content: newContent } ); + }; + return ( + <RichText + tagName="p" + className={ className } + onChange={ onChangeContent } + value={ content } + /> + ); + }, + save: ( props ) => { + return <RichText.Content tagName="p" value={ props.attributes.content } />; + }, +} ); +``` +{% end %} diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/readme.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/readme.md new file mode 100644 index 0000000000000..a3ee5191dbca7 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/readme.md @@ -0,0 +1,9 @@ +# Blocks + +The purpose of this tutorial is to step through the fundamentals of creating a new block type. Beginning with the simplest possible example, each new section will incrementally build upon the last to include more of the common functionality you could expect to need when implementing your own block types. + +To follow along with this tutorial, you can [download the accompanying WordPress plugin](https://github.com/WordPress/gutenberg-examples) which includes all of the examples for you to try on your own site. At each step along the way, you should feel free to experiment by modifying the examples with your own ideas and observing the effects they have on the block's behavior. + +Code snippets are provided both for "classic" JavaScript (ECMAScript 5, or "ES5"), as well as newer versions of the language standard (ES2015 and newer, or "ESNext"). You can change between them using tabs found above each code example. When choosing to author your blocks with ESNext, you need to run [the JavaScript build step](/docs/designers-developers/developers/tutorials/javascript/js-build-setup/) in order to support older browsers. + +Note that it is not required to use ESNext to create a new block, and you are welcome to use classic JavaScript if you so choose. diff --git a/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md new file mode 100644 index 0000000000000..a4ac0f7a33de9 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md @@ -0,0 +1,108 @@ +# Writing Your First Block Type + +To keep things simple for our first example, let's create a new block type which displays a styled message in a post. At this point, we won't allow the user to edit the message. We'll learn more about editable fields in later sections. + +Blocks containing static content are implemented entirely in JavaScript using the `registerBlockType` function. This function is responsible for specifying the blueprint of a block, describing the behaviors necessary for the editor to understand how it appears, changes when edited, and is ultimately saved in the post's content. + +## Enqueuing Block Scripts + +While the block's editor behaviors are implemented in JavaScript, you'll need to register your block server-side to ensure that the script is enqueued when the editor loads. Register scripts and styles using [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) and [`wp_register_style`](https://developer.wordpress.org/reference/functions/wp_register_style/), then assign these as handles associated with your block using the `script`, `style`, `editor_script`, and `editor_style` block type registration settings. The `editor_`-prefixed handles will only be enqueued in the context of the editor, while `script` and `style` will be enqueued both in the editor and when viewing a post on the front of your site. + +```php +<?php +/* +Plugin Name: Gutenberg examples 01 +*/ +function gutenberg_examples_01_register_block() { + wp_register_script( + 'gutenberg-examples-01', + plugins_url( 'block.js', __FILE__ ), + array( 'wp-blocks', 'wp-element' ) + ); + + register_block_type( 'gutenberg-examples/example-01-basic', array( + 'editor_script' => 'gutenberg-examples-01', + ) ); + +} +add_action( 'init', 'gutenberg_examples_01_register_block' ); +``` + +Note the two script dependencies: + +- __`wp-blocks`__ includes block type registration and related functions +- __`wp-element`__ includes the [WordPress Element abstraction](/packages/element/README.md) for describing the structure of your blocks + +If you were to use a component from the `wp-editor` package, for example the RichText component, you would also need to add `wp-editor` to the dependency list. + +## Registering the Block + +With the script enqueued, let's look at the implementation of the block itself: + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, element ) { + var el = element.createElement; + + var blockStyle = { + backgroundColor: '#900', + color: '#fff', + padding: '20px', + }; + + blocks.registerBlockType( 'gutenberg-examples/example-01-basic', { + title: 'Example: Basic', + icon: 'universal-access-alt', + category: 'layout', + edit: function() { + return el( + 'p', + { style: blockStyle }, + 'Hello World, step 1 (from the editor).' + ); + }, + save: function() { + return el( + 'p', + { style: blockStyle }, + 'Hello World, step 1 (from the frontend).' + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element +) ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; + +const blockStyle = { + backgroundColor: '#900', + color: '#fff', + padding: '20px', +}; + +registerBlockType( 'gutenberg-examples/example-01-basic-esnext', { + title: 'Example: Basic (esnext)', + icon: 'universal-access-alt', + category: 'layout', + edit() { + return <div style={ blockStyle }>Hello World, step 1 (from the editor).</div>; + }, + save() { + return <div style={ blockStyle }>Hello World, step 1 (from the frontend).</div>; + }, +} ); +``` +{% end %} + +_By now you should be able to see `Hello World, step 1 (from the editor).` in the admin side and `Hello World, step 1 (from the frontend).` on the frontend side._ + +Once a block is registered, you should immediately see that it becomes available as an option in the editor inserter dialog, using values from `title`, `icon`, and `category` to organize its display. You can choose an icon from any included in the built-in [Dashicons icon set](https://developer.wordpress.org/resource/dashicons/), or provide a [custom svg element](/docs/designers-developers/developers/block-api/block-registration.md#icon-optional). + +A block name must be prefixed with a namespace specific to your plugin. This helps prevent conflicts when more than one plugin registers a block with the same name. In this example, the namespace is `gutenberg-examples`. + +The `edit` and `save` functions describe the structure of your block in the context of the editor and the saved content respectively. While the difference is not obvious in this simple example, in the following sections we'll explore how these are used to enable customization of the block in the editor preview. diff --git a/gb-src/docs/designers-developers/developers/tutorials/format-api/1-register-format.md b/gb-src/docs/designers-developers/developers/tutorials/format-api/1-register-format.md new file mode 100644 index 0000000000000..7a0f01e5fe0e9 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/format-api/1-register-format.md @@ -0,0 +1,63 @@ +# Register a New Format + +The first thing you're going to do in this tutorial is to register the new format that the plugin intends to apply. WordPress has the [`registerFormatType`](/packages/rich-text/README.md#registerFormatType) function to do so. + +Let's prepare a minimal plugin to make this work. Create a new folder and a file named `my-custom-format.php` within it containing the necessary PHP code to register and enqueue the JavaScript assets: + +```php +<?php +/** + * Plugin Name: My custom format + */ + +function my_custom_format_script_register() { + wp_register_script( + 'my-custom-format-js', + plugins_url( 'my-custom-format.js', __FILE__ ), + array( 'wp-rich-text' ) + ); +} +add_action( 'init', 'my_custom_format_script_register' ); + +function my_custom_format_enqueue_assets_editor() { + wp_enqueue_script( 'my-custom-format-js' ); +} +add_action( 'enqueue_block_editor_assets', 'my_custom_format_enqueue_assets_editor' ); +``` + +Then add a new file named `my-custom-format.js` with the following contents: + +{% codetabs %} +{% ES5 %} +```js +( function( wp ) { + wp.richText.registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + } + ); +} )( window.wp ); +``` +{% ESNext %} +```js +const { registerFormatType } = wp.richText; + +registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + } +); +``` +{% end %} + +Make that plugin available in your WordPress setup and activate it. Then, load a new page/post. + +The list of available format types is maintained in the `core/rich-text` store. You can query the store to check that your custom format is now available. To do so, run this code in your browser's console: + + wp.data.select( 'core/rich-text' ).getFormatTypes(); + +It'll return an array containing the format types, including your own. diff --git a/gb-src/docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md b/gb-src/docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md new file mode 100644 index 0000000000000..d6517b93ccc10 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md @@ -0,0 +1,158 @@ +# Add a Button to the Toolbar + +Now that the format is available, the next step is to surface it to the UI. You can make use of the [`RichTextToolbarButton`](/packages/editor/src/components/rich-text/README.md#RichTextToolbarButton) component to extend the format toolbar. + +Paste this code in `my-custom-format.js`: + +{% codetabs %} +{% ES5 %} +```js +( function( wp ) { + var MyCustomButton = function( props ) { + return wp.element.createElement( + wp.editor.RichTextToolbarButton, { + icon: 'editor-code', + title: 'Sample output', + onClick: function() { + console.log( 'toggle format' ); + }, + } + ); + } + wp.richText.registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: MyCustomButton, + } + ); +} )( window.wp ); +``` +{% ESNext %} +```js +const { registerFormatType } = wp.richText; +const { RichTextToolbarButton } = wp.blockEditor; + +const MyCustomButton = props => { + return <RichTextToolbarButton + icon='editor-code' + title='Sample output' + onClick={ () => { + console.log( 'toggle format' ); + } } + /> +}; + +registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: MyCustomButton, + } +); +``` +{% end %} + +**Important**: note that this code is using two new utilities (`wp.element.createElement`, and `wp.editor.RichTextToolbarButton`) so don't forget adding the corresponding `wp-element` and `wp-editor` packages to the dependencies array in the PHP file along with the existing `wp-rich-text`. + +Let's check that everything is working as expected. Reload the post/page and select a text block. Make sure that the new button was added to the format toolbar, it uses the [editor-code dashicon](https://developer.wordpress.org/resource/dashicons/#editor-code), and the hover text is what you set in the title: + +![Toolbar with custom button](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/toolbar-with-custom-button.png) + +You may also want to check that upon clicking the button the `toggle format` message is shown in your browser's console. + +## Show the button only for specific blocks + +By default, the button is rendered on every rich text toolbar (image captions, buttons, paragraphs, etc). +It is possible to render the button only on blocks of a certain type by using `wp.data.withSelect` together with `wp.compose.ifCondition`. +The following sample code renders the previously shown button only on Paragraph blocks: + +{% codetabs %} +{% ES5 %} +```js +( function( wp ) { + var withSelect = wp.data.withSelect; + var ifCondition = wp.compose.ifCondition; + var compose = wp.compose.compose; + var MyCustomButton = function( props ) { + return wp.element.createElement( + wp.editor.RichTextToolbarButton, { + icon: 'editor-code', + title: 'Sample output', + onClick: function() { + console.log( 'toggle format' ); + }, + } + ); + } + var ConditionalButton = compose( + withSelect( function( select ) { + return { + selectedBlock: select( 'core/editor' ).getSelectedBlock() + } + } ), + ifCondition( function( props ) { + return ( + props.selectedBlock && + props.selectedBlock.name === 'core/paragraph' + ); + } ) + )( MyCustomButton ); + + wp.richText.registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: ConditionalButton, + } + ); +} )( window.wp ); +``` +{% ESNext %} +```js +const { compose, ifCondition } = wp.compose; +const { registerFormatType } = wp.richText; +const { RichTextToolbarButton } = wp.blockEditor; +const { withSelect } = wp.data; + +const MyCustomButton = props => { + return <RichTextToolbarButton + icon='editor-code' + title='Sample output' + onClick={ () => { + console.log( 'toggle format' ); + } } + /> +}; + +const ConditionalButton = compose( + withSelect( function( select ) { + return { + selectedBlock: select( 'core/editor' ).getSelectedBlock() + } + } ), + ifCondition( function( props ) { + return ( + props.selectedBlock && + props.selectedBlock.name === 'core/paragraph' + ); + } ) +)( MyCustomButton ); + +registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: ConditionalButton, + } +); +``` +{% end %} + +Don't forget adding `wp-compose` and `wp-data` to the dependencies array in the PHP script. + +More advanced conditions can be used, e.g., only render the button depending on specific attributes of the block. diff --git a/gb-src/docs/designers-developers/developers/tutorials/format-api/3-apply-format.md b/gb-src/docs/designers-developers/developers/tutorials/format-api/3-apply-format.md new file mode 100644 index 0000000000000..8b822b3a75845 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/format-api/3-apply-format.md @@ -0,0 +1,74 @@ +# Apply the Format When the Button Is Clicked + +So far, your custom button doesn't modify the text selected, it only renders a message in the console. Let's change that. + +The [rich-text package](/packages/rich-text/README.md) offers a few utilities to work with formats: [applyFormat](/packages/rich-text/README.md#applyFormat), [removeFormat](/packages/rich-text/README.md#removeFormat), and [toggleFormat](/packages/rich-text/README.md#toggleFormat). In this particular example, the format you want to apply (the `<samp>` tag) may be considered binary - either a text selection has the tag or not. Taking that into account, the `toggleFormat` primitive seems more convenient. + +Update `my-custom-format.js` with this new code: + +{% codetabs %} +{% ES5 %} +```js +( function( wp ) { + var MyCustomButton = function( props ) { + return wp.element.createElement( + wp.blockEditor.RichTextToolbarButton, { + icon: 'editor-code', + title: 'Sample output', + onClick: function() { + props.onChange( wp.richText.toggleFormat( + props.value, + { type: 'my-custom-format/sample-output' } + ) ); + }, + isActive: props.isActive, + } + ); + } + wp.richText.registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: MyCustomButton, + } + ); +} )( window.wp ); +``` +{% ESNext %} +```js +const { registerFormatType, toggleFormat } = wp.richText +const { RichTextToolbarButton } = wp.blockEditor; + +const MyCustomButton = props => { + return <RichTextToolbarButton + icon='editor-code' + title='Sample output' + onClick={ () => { + props.onChange( toggleFormat( + props.value, + { type: 'my-custom-format/sample-output' } + ) ); + } } + isActive={ props.isActive } + /> +}; + +registerFormatType( + 'my-custom-format/sample-output', { + title: 'Sample output', + tagName: 'samp', + className: null, + edit: MyCustomButton, + } +); +``` +{% end %} + +Now, let's check that is working as intended: reload the post/page, make a text selection, click the button, and then change to HTML view to confirm that the tag was effectively applied. + +The expected behavior is that the format will be toggled, meaning that the text selected will be wrapped by a `<samp>` tag if it isn't yet, or the tag will be removed if the selection is already wrapped with the tag. Notice that the button renders a different style depending on whether the selection has the tag or not as well - this is controlled by the `isActive` property of the `RichTextToolbarButton` component. + +Your browser may have already displayed the selection differently once the tag was applied, but you may want to use a special style of your own. You can use the `className` option in [`registerFormatType`](/packages/rich-text/README.md#registerFormatType) to target the new element by class name: if `className` is set, it'll be added to the new element. + +That's it. This is all that is necessary to make a custom format available in the new editor. From here, you may want to check out other [tutorials](/docs/designers-developers/developers/tutorials/) or apply your new knowledge to your next plugin! diff --git a/gb-src/docs/designers-developers/developers/tutorials/format-api/README.md b/gb-src/docs/designers-developers/developers/tutorials/format-api/README.md new file mode 100644 index 0000000000000..3ecb76c07e059 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/format-api/README.md @@ -0,0 +1,13 @@ +# Introduction to the Format API + +The purpose of this tutorial is to introduce you to the Format API. The Format API makes it possible for developers to add custom buttons to the formatting toolbar and have them apply a _format_ to a text selection. Bold is an example of a standard button in the formatting toolbar. + +In WordPress lingo, a _format_ is a [HTML tag with text-level semantics](https://www.w3.org/TR/html5/textlevel-semantics.html#text-level-semantics-usage-summary) used to give some special meaning to a text selection. For example, in this tutorial, the button to be hooked into the format toolbar will let users wrap a particular text selection with the [`<samp>` HTML tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/samp). + +If you are unfamiliar with how to work with WordPress plugins and JavaScript, you may want to check the [JavaScript Tutorial](/docs/designers-developers/developers/tutorials/javascript/readme.md) first. + +## Table of Contents + +1. [Register a new format](/docs/designers-developers/developers/tutorials/format-api/1-register-format.md) +2. [Add a button to the toolbar](/docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md) +3. [Apply the format when the button is clicked](/docs/designers-developers/developers/tutorials/format-api/3-apply-format.md) diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md new file mode 100644 index 0000000000000..19a1805718762 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md @@ -0,0 +1,67 @@ +# Extending the Block Editor + +Let's look at using the [Block Style Variation example](/docs/designers-developers/developers/filters/block-filters.md#block-style-variations) to extend the editor. This example allows you to add your own custom CSS class name to any core block type. + +Replace the existing `console.log()` code in your `myguten.js` file with: + +```js +wp.blocks.registerBlockStyle( 'core/quote', { + name: 'fancy-quote', + label: 'Fancy Quote' +} ); +``` + +**Important:** Notice that you are using a function from `wp.blocks` package. This means you must specify it as a dependency when you enqueue the script. Update the `myguten-plugin.php` file to: + +```php +<?php +/* +Plugin Name: Fancy Quote +*/ + +function myguten_enqueue() { + wp_enqueue_script( 'myguten-script', + plugins_url( 'myguten.js', __FILE__ ), + array( 'wp-blocks' ) + ); +} +add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' ); +``` + +The last argument in the `wp_enqueue_script()` function is an array of dependencies. WordPress makes packages available under the `wp` namespace. In the example, you use `wp.blocks` to access the items that the blocks package exports (in this case the `registerBlockStyle()` function). + +See [Packages](/docs/designers-developers/developers/packages.md) for list of available packages and what objects they export. + +After you have updated both JavaScript and PHP files, go to the block editor and create a new post. + +Add a quote block, and in the right sidebar under Styles, you will see your new Fancy Quote style listed. + +Click the Fancy Quote to select and apply that style to your quote block: + + +![Fancy Quote Style in Inspector](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/fancy-quote-in-inspector.png) + + +Even if you Preview or Publish the post you will not see a visible change. However, if you look at the source, you will see the `is-style-fancy-quote` class name is now attached to your quote block. + +Let's add some style. In your plugin folder, create a `style.css` file with: + +```css +.is-style-fancy-quote { + color: tomato; +} +``` + +You enqueue the CSS file by adding the following to your `myguten-plugin.php`: + +```php +function myguten_stylesheet() { + wp_enqueue_style( 'myguten-style', plugins_url( 'style.css', __FILE__ ) ); +} +add_action( 'enqueue_block_assets', 'myguten_stylesheet' ); +``` + +Now when you view in the editor and publish, you will see your Fancy Quote style, a delicious tomato color text: + +![Fancy Quote with Style](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/fancy-quote-with-style.png) + diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md new file mode 100644 index 0000000000000..410e8b8be1c38 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md @@ -0,0 +1,159 @@ +# JavaScript Build Setup + +This page covers how to set up your development environment to use the ESNext and [JSX](https://reactjs.org/docs/introducing-jsx.html) syntaxes. ESNext is JavaScript code written using features that are only available in a specification greater than ECMAScript 5 (ES5 for short). JSX is a custom syntax extension to JavaScript which helps you to describe what the UI should look like. + +This documentation covers development for your plugin to work with the new setup provided by the Gutenberg project (ie: the block editor). If you want to develop Gutenberg itself, see the [Getting Started](/docs/contributors/getting-started.md) documentation. + +Most browsers can not interpret or run ESNext and JSX syntaxes, so we use a transformation step to convert these syntaxes to code that browsers can understand. + +There are a few reasons to use ESNext and the extra step. First, it makes for simpler code that is easier to read and write. Using a transformation step allows for tools to optimize the code to work on the widest variety of browsers. Also, by using a build step you can organize your code into smaller modules and files that can be bundled together into a single download. + +There are different tools that can perform this transformation or build step, but WordPress uses webpack and Babel. + +[webpack](https://webpack.js.org/) is a pluggable tool that processes JavaScript, creating a compiled bundle that runs in a browser. [Babel](https://babeljs.io/) transforms JavaScript from one format to another. You use Babel as a plugin to webpack to transform both ESNext and JSX to JavaScript. + +The [@wordpress/scripts](https://www.npmjs.com/package/@wordpress/scripts) package abstracts these libraries away to standardize and simplify development, so you won't need to handle the details for configuring those libraries. + +## Quick Start + +For a quick start, you can use one of the examples from the [Gutenberg Examples repository](https://github.com/wordpress/gutenberg-examples/). Each one of the `-esnext` directories contain the necessary files for working with ESNext and JSX. + +## Setup + +Both webpack and Babel are tools written in JavaScript and run using [Node.js](https://nodejs.org/) (node). Node.js is a runtime environment for JavaScript outside of a browser. Simply put, node allows you to run JavaScript code on the command-line. + +First, you need to set up node for your development environment. The steps required change depending on your operating system, but if you have a package manager installed, setup can be as straightforward as: + +- Ubuntu: `apt install node` +- macOS: `brew install node` +- Windows: `choco install node` + +Additionally, the [Node.js download page](https://nodejs.org/en/download/) includes installers and binaries. + +**Note:** The build tools and process occur on the command-line, so some basic familiarity using a terminal application is required. Some text editors have a terminal built-in which is fine to use; Visual Studio Code and PhpStorm are two popular options. + +### Node Package Manager (npm) + +The Node Package Manager (npm) is a tool included with node. npm allows you to install and manage JavaScript packages. npm can also generate and process a special file called `package.json`, which contains some information about your project and the packages your project uses. + +To start a new node project, first create a directory to work in: + +``` +mkdir myguten-block +cd myguten-block +``` + +You create a new package.json running `npm init` in your terminal. This will walk you through creating your package.json file: + +``` +npm init + +package name: (myguten-block) myguten-block +version: (1.0.0) +description: Test block +entry point: (index.js) build/index.js +test command: +git repository: +keywords: +author: mkaz +license: (ISC) GPL-2.0-only +About to write to /home/mkaz/src/wp/scratch/package.json: + +{ + "name": "myguten-block", + "version": "1.0.0", + "description": "Test block", + "main": "block.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "mkaz", + "license": "GPL-2.0-only" +} + + +Is this OK? (yes) yes +``` + +### Using npm to install packages + +The next step is to install the packages required. You can install packages using the npm command `npm install`. If you pass the `--save-dev` parameter, npm will write the package as a dev dependency in the package.json file. The `--save-exact` parameter instructs npm to save an exact version of a dependency, not a range of valid versions. See [npm install documentation](https://docs.npmjs.com/cli/install) for more details. + +Run `npm install --save-dev --save-exact @wordpress/scripts` + +After installing, a `node_modules` directory is created with the modules and their dependencies. + +Also, if you look at package.json file it will include a new section: + +```json +"devDependencies": { + "@wordpress/scripts": "3.1.0" +} +``` + +## Webpack & Babel + +The `@wordpress/scripts` package handles the dependencies and default configuration for webpack and Babel. The scripts package expects the source file to compile to be found at `src/index.js`, and will save the compiled output to `build/index.js`. + +With that in mind, let's set up a basic block. Create a file at `src/index.js` with the following content: + +```js +const { registerBlockType } = wp.blocks; + +registerBlockType( 'myguten/test-block', { + title: 'Basic Example', + icon: 'smiley', + category: 'layout', + edit: () => <div>Hola, mundo!</div>, + save: () => <div>Hola, mundo!</div>, +} ); +``` + +To configure npm to run a script, you use the scripts section in `package.json` webpack: + +```json + "scripts": { + "build": "wp-scripts build" + }, +``` + +You can then run the build using: `npm run build`. + +After the build finishes, you will see the built file created at `build/index.js`. + +## Finishing Touches + +### Development Mode + +The **build** command in `@wordpress/scripts` runs in a "production" mode. This shrinks the code down so it downloads faster, but makes it difficult to read in the process. You can use the **start** command which runs a development mode that does not shrink the code, and additionally continues a running process to watch the source file for more changes and rebuild as you develop. + +The start command can be added to the same scripts section of `package.json`: + +```json + "scripts": { + "start": "wp-scripts start", + "build": "wp-scripts build" + }, +``` + +Now, when you run `npm start` a watcher will run in the terminal. You can then edit away in your text editor; after each save, it will automatically build. You can then use the familiar edit/save/reload development process. + +**Note:** keep an eye on your terminal for any errors. If you make a typo or syntax error, the build will fail and the error will be in the terminal. + + +### Source Control + +Because a typical `node_modules` folder will contain thousands of files that change with every software update, you should exclude `node_modules/` from your source control. If you ever start from a fresh clone, simply run `npm install` in the same folder your `package.json` is located to pull your required packages. + +Likewise, you do not need to include `node_modules` or any of the above configuration files in your plugin because they will be bundled inside the file that webpack builds. **Be sure to enqueue the `build/index.js` file** in your plugin PHP. This is the only JavaScript file needed for your block to run. + +## Summary + +Yes, the initial setup is a bit more involved, but the additional features and benefits are usually worth the trade off in setup time. + +With a setup in place, the standard workflow is: + +1. Install dependencies: `npm install` +2. Start development builds: `npm start` +3. Develop. Test. Repeat. +4. Create production build: `npm run build` diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md new file mode 100644 index 0000000000000..f48775df5782d --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md @@ -0,0 +1,49 @@ +# Loading JavaScript + +With the plugin in place, you can add the code that loads the JavaScript. This methodology follows the standard WordPress procedure of enqueuing scripts, see [enqueuing section of the Plugin Handbook](https://developer.wordpress.org/plugins/javascript/enqueuing/). + +Add the following code to your `myguten-plugin.php` file: + +```php +function myguten_enqueue() { + wp_enqueue_script( + 'myguten-script', + plugins_url( 'myguten.js', __FILE__ ) + ); +} +add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' ); +``` + +The `enqueue_block_editor_assets` hook is used, which is called when the block editor loads, and will enqueue the JavaScript file `myguten.js`. + +Create a file called `myguten.js` and add: + +```js +console.log( "I'm loaded!" ); +``` + +Next, create a new post in the block editor. + +We'll check the JavaScript console in your browser's Developer Tools, to see if the message is displayed. If you're not sure what developer tools are, Mozilla's ["What are browser developer tools?"](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools) documentation provides more information, including more background on the [JavaScript console](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools#The_JavaScript_console). + +If your code is registered and enqueued correctly, you should see a message in your console: + +![Console Log Message Success](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/js-tutorial-console-log-success.png) + +**Note for Theme Developers:** The above method of enqueuing is used for plugins. If you are extending the block editor for your theme there is a minor difference, you will use the `get_template_directory_uri()` function instead of `plugins_url()`. So for a theme, the enqueue example is: + +```php +function myguten_enqueue() { + wp_enqueue_script( + 'myguten-script', + get_template_directory_uri() . '/myguten.js' + ); +} +add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' ); +``` + +### Recap + +At this point, you have a plugin in the directory `wp-content/plugins/myguten-plugin` with two files: the PHP server-side code in `myguten-plugin.php`, and the JavaScript which runs in the browser in `myguten.js`. + +This puts all the initial pieces in place for you to start extending the block editor. diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/plugins-background.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/plugins-background.md new file mode 100644 index 0000000000000..2b1ed5d5f5642 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/plugins-background.md @@ -0,0 +1,22 @@ +# Plugins Background + +The primary means of extending WordPress is the plugin. The WordPress [Plugin Basics](https://developer.wordpress.org/plugins/plugin-basics/) documentation provides details on building a plugin. + +The quickest way to start is to create a new directory in `wp-content/plugins/` to contain your plugin code. For this example, call it `myguten-plugin`. + +Inside this new directory, create a file called `myguten-plugin.php`. This is the server-side code that runs when your plugin is active. + +For now, add the following code in the file: + +```php +<?php +/* +Plugin Name: Fancy Quote +*/ +``` + +To summarize, you should have a directory `wp-content/plugins/myguten-plugin/` which has the single file `myguten-plugin.php`. + +Once that is in place, go to your plugins list in `wp-admin` and you should see your plugin listed. + +Click **Activate** and your plugin will load with WordPress. diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/readme.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/readme.md new file mode 100644 index 0000000000000..af8a895461e41 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/readme.md @@ -0,0 +1,19 @@ +# Getting Started with JavaScript + +The purpose of this tutorial is to step through getting started with JavaScript and WordPress, specifically around the new block editor. The Block Editor Handbook contains information on the APIs available for working with this new setup. The goal of this tutorial is to get you comfortable on how to use the API reference and snippets of code found within. + +### What is JavaScript + +JavaScript is a programming language which is loaded and executed in your web browser; compared to PHP which is run by a web server with the results sent to the browser, typically as HTML. + +The block editor introduced in WordPress 5.0 is written in JavaScript, with the code run in the browser, and not on the server, this allows for a richer and more dynamic user experience. It also requires you to learn how to use JavaScript to extend and enhance the block editor. + + +### Table of Contents + +1. [Plugins Background](/docs/designers-developers/developers/tutorials/javascript/plugins-background.md) +2. [Loading JavaScript](/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md) +3. [Extending the Block Editor](/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md) +4. [Troubleshooting](/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md) +5. [JavaScript Versions and Building](/docs/designers-developers/developers/tutorials/javascript/versions-and-building.md) +6. [Scope your code](/docs/designers-developers/developers/tutorials/javascript/scope-your-code.md) diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/scope-your-code.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/scope-your-code.md new file mode 100644 index 0000000000000..802e4872cf8d5 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/scope-your-code.md @@ -0,0 +1,117 @@ +# Scope Your Code + +Historically, JavaScript files loaded in a web page share the same scope. This means that a global variable declared in one file will be seen by the code in other files. + +To see how this works, create a web page that loads three JavaScript files. The `first.js` file will be: + +```js +var pluginName = 'MyPlugin'; +console.log( 'Plugin name is ', pluginName ); +``` + +Let's create `second.js` as: + +```js +var pluginName = 'DifferentPlugin'; +console.log( 'Plugin name is ', pluginName ); +``` + +And, finally, `third.js`: + +```js +console.log( 'Plugin name is ', pluginName ); +``` + +When loaded on the same page, `first.js` and `second.js` will output the plugin name declared within itself. They will override the value of the global `pluginName` variable if one was already declared. It's not known what gets printed in the console when `third.js` is executed, though - it depends on the value of the global `pluginName` variable when `third.js` is executed, which will depend on the order the files are loaded. + +This behavior can be problematic, and is the reason we need to scope the code. By scoping the code—ensuring each file is isolated from each other—we can prevent values unexpectedly changing. + +## Scoping Code Within a Function + +In JavaScript, you can scope your code by writing it within a function. Functions have "local scope", or a scope that is specific only to that function. Additionally, in JavaScript you can write anonymous functions, functions without a name, which will also prevent your function name from being overridden in the global scope. + +Taking advantage of these two JavaScript features, `first.js` could be scoped as: + +```js +function() { + var pluginName = 'MyPlugin'; + console.log( 'Plugin name is ', pluginName ); +} +``` + +`second.js` as: + +```js +function() { + var pluginName = 'DifferentPlugin'; + console.log( 'Plugin name is ', pluginName ); +} +``` + +And `third.js`: + +```js +function() { + console.log( 'Plugin name is ', pluginName ); +} +``` + +With this trick, the different files won't override each other's variables. Unfortunately, they also won't work as expected, because these functions are being called by no one. We've only _defined_ the functions; we haven't _executed_ them yet. + +## Automatically Execute Anonymous Functions + +It turns out there are a few ways to execute anonymous functions in JavaScript, but the most popular is this: + +```js +( function() { + // your code goes here +} )( ) +``` + +You wrap your function between parentheses, and then call it like any other named function. This pattern is known as [Immediately-Invoked Function Expression](http://benalman.com/news/2010/11/immediately-invoked-function-expression/), or IIFE for short. + +This is `first.js` written as an IIFE: + +```js +( function() { + var pluginName = 'MyPlugin'; + console.log( 'Plugin name is ', pluginName ); +} )( ) +``` + +And this is `second.js`: + +```js +( function() { + var pluginName = 'DifferentPlugin'; + console.log( 'Plugin name is ', pluginName ); +} )( ) +``` + +And this is `third.js`: + +```js +( function() { + console.log( 'Plugin name is ', pluginName ); +} )( ) +``` + +The code in `first.js` and `second.js` is unaffected by other variables in the global scope, so it's safe and deterministic. + +On the other hand, `third.js` doesn't declare a `pluginName` variable, but needs to be provided one. IIFEs still allow you to take a variable from the global scope and pass it into your function. Provided that there was a global `window.pluginName` variable, we could rewrite `third.js` as: + +```js +( function( name ) { + console.log( 'Plugin name is ', name ); +} )( window.pluginName ) +``` + +## Future Changes + +At the beginning we mentioned that: + +> Historically, JavaScript files loaded in a web page share the same scope. + +Notice the _historically_. + +JavaScript has evolved quite a bit since its creation. As of 2015, the language supports modules, also known as _ES6 modules_, that introduce separate scope per file: a global variable in `first.js` wouldn't be exposed to `second.js`. This feature is already [supported by modern browsers](https://caniuse.com/#feat=es6-module), but not all of them do. If your code needs to run in browsers that don't support modules, your last resort is using IIFEs. diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md new file mode 100644 index 0000000000000..63affa34495d4 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md @@ -0,0 +1,34 @@ +# Troubleshooting + +If you're having trouble getting your code to work, here are a few ways to troubleshoot. + +## Console Log + +The console log is a JavaScript developer's best friend. It is a good practice to work with it open, as it collects errors and notices into one place. See Mozilla's [JavaScript console](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools#The_JavaScript_console) documentation for more. + +Your first step in debugging should be to check the JavaScript console for any errors. Here is an example, which shows a syntax error on line 6: + +![console error](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/js-tutorial-console-log-error.png) + +## Confirm JavaScript is Loading + +If you are not seeing your changes, check that your JavaScript file is being enqueued. Open the page source in your browser's web inspector (some browsers may allow you to view the page source by right clicking on the page and selecting "View Page Source"), and look for the `<script>` tag that loads your file. In our example, you would search for `myguten.js` and confirm it is being loaded. + +If you do not see the file being loaded, doublecheck the enqueue function is correct. You can also check your server logs to see if there is an error messages. + +## Confirm All Dependencies Are Loaded + +The console log will show an error if a dependency your JavaScript code uses has not been declared and loaded in the browser. In the example, if `myguten.js` script is enqueued without declaring the `wp-blocks` dependency, the console log will show: + +<img src="https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png" width=448 title="error wp.blocks is undefined"/> + +You can correct by checking your `wp_enqueue_script` function includes all packages listed that are used: + +```js +wp_enqueue_script( + 'myguten-script', + plugins_url( 'myguten.js', __FILE__ ), + array( 'wp-blocks' ) +); +``` + diff --git a/gb-src/docs/designers-developers/developers/tutorials/javascript/versions-and-building.md b/gb-src/docs/designers-developers/developers/tutorials/javascript/versions-and-building.md new file mode 100644 index 0000000000000..86900a89b8619 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/javascript/versions-and-building.md @@ -0,0 +1,12 @@ +# JavaScript Versions and Build Step + +The Block Editor Handbook shows JavaScript examples in two syntaxes: ES5 and ESNext. These are version names for the JavaScript language standard definitions. You may also see elsewhere the names ES6, or ECMAScript 2015 mentioned. See the [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript) Wikipedia article for all the details. + +ES5 code is compatible with WordPress's minimum [target for browser support](https://make.wordpress.org/core/handbook/best-practices/browser-support/). + +"ESNext" doesn't refer to a specific version of JavaScript, but is "dynamic" and refers to the next language definitions, whatever they might be. Because some browsers won't support these features yet (because they're new or proposed), an extra build step is required to transform the code to a syntax that works in all browsers. Webpack and babel are the tools that perform this transformation step. + +Additionally, the ESNext code examples in the handbook include [JSX syntax](https://reactjs.org/docs/introducing-jsx.html), a syntax that blends HTML and JavaScript. It makes it easier to read and write markup code, but likewise requires the build step using Webpack and Babel to transform into compatible code. + +For simplicity, the JavaScript tutorial uses the ES5 definition, without JSX. This code can run straight in your browser and does not require an additional build step. In many cases, it will be perfectly fine to follow the same approach to quickly start experimenting with your plugin or theme. As soon as your codebase grows to hundreds of lines it might be a good idea to get familiar with the [JavaScript Build Setup documentation](/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md) for setting up a development environment to use ESNext syntax. + diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md new file mode 100644 index 0000000000000..c89c757a92209 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md @@ -0,0 +1,17 @@ +# Store Post Meta with a Block + +Typically, blocks store their attribute values in the serialised block HTML. However, you can also create a block that saves its attribute values as post meta, which can be accessed programatically anywhere in your template. + +In this short tutorial you will create one of these blocks, which will prompt a user for a single value, and save it as post meta. + +For background around the thinking of blocks as the interface, please see the [Principles section](/docs/contributors/principles.md) of the handbook. + +Before starting this tutorial, you will need a plugin to hold your code. Please take a look at the first two steps of [the JavaScript tutorial](/docs/designers-developers/developers/tutorials/javascript/readme.md) for information setting up a plugin. + +## Table of Contents + +1. [Register Meta Field](/docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md) +2. [Add Meta Block](/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md) +3. [Use Post Meta Data](/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md) +4. [Finishing Touches](/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md) + diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md new file mode 100644 index 0000000000000..744e50dfae534 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md @@ -0,0 +1,37 @@ +# Register Meta Field + +A post meta field is a WordPress object used to store extra data about a post. You need to first register a new meta field prior to use. See Managing [Post Metadata](https://developer.wordpress.org/plugins/metadata/managing-post-metadata/) to learn more about post meta. + +When registering the field, note the `show_in_rest` parameter. This ensures the data will be included in the REST API, which the block editor uses to load and save meta data. See the [`register_post_meta`](https://developer.wordpress.org/reference/functions/register_post_meta/) function definition for extra information. + +To register the field, create a PHP plugin file called `myguten-meta-block.php` including: + +```php +<?php +/** + * Plugin Name: Meta Block + */ + +// register custom meta tag field +function myguten_register_post_meta() { + register_post_meta( 'post', 'myguten_meta_block_field', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) ); +} +add_action( 'init', 'myguten_register_post_meta' ); +``` + +**Note:** If the meta key name starts with an underscore WordPress considers it a protected field. Editing this field requires passing a permission check, which is set as the `auth_callback` in the `register_post_meta` function. Here is an example: + +```php +register_post_meta( 'post', '_myguten_protected_key', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'auth_callback' => function() { + return current_user_can( 'edit_posts' ); + } +) ); +``` diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md new file mode 100644 index 0000000000000..67bec79789d84 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md @@ -0,0 +1,123 @@ +# Create Meta Block + +With the meta field registered in the previous step, next you will create a new block used to display the field value to the user. See the [Block Tutorial](/docs/designers-developers/developers/tutorials/block-tutorial/readme.md) for a deeper understanding of creating custom blocks. + +For this block, you will use the TextControl component, which is similar to an HTML input text field. For additional components, check out the [components](/packages/components/src) and [editor](/packages/editor/src/components) packages repositories. + +Attributes are the information displayed in blocks. As shown in the block tutorial, the source of attributes come from the text or HTML a user writes in the editor. For your meta block, the attribute will come from the post meta field. + +By specifying the source of the attributes as `meta`, the block editor automatically handles the loading and storing of the data; no REST API or data functions are needed. + +Add this code to your JavaScript file (this tutorial will call the file `myguten.js`): + +{% codetabs %} +{% ES5 %} +```js +( function( wp ) { + var el = wp.element.createElement; + var registerBlockType = wp.blocks.registerBlockType; + var TextControl = wp.components.TextControl; + + registerBlockType( 'myguten/meta-block', { + title: 'Meta Block', + icon: 'smiley', + category: 'common', + + attributes: { + blockValue: { + type: 'string', + source: 'meta', + meta: 'myguten_meta_block_field' + } + }, + + edit: function( props ) { + var className = props.className; + var setAttributes = props.setAttributes; + + function updateBlockValue( blockValue ) { + setAttributes({ blockValue }); + } + + return el( + 'div', + { className: className }, + el( TextControl, { + label: 'Meta Block Field', + value: props.attributes.blockValue, + onChange: updateBlockValue + } ) + ); + }, + + // No information saved to the block + // Data is saved to post meta via attributes + save: function() { + return null; + } + } ); +} )( window.wp ); +``` +{% ESNext %} +```jsx +const { registerBlockType } = wp.blocks; +const { TextControl } = wp.components; + +registerBlockType( 'myguten/meta-block', { + title: 'Meta Block', + icon: 'smiley', + category: 'common', + + attributes: { + blockValue: { + type: 'string', + source: 'meta', + meta: 'myguten_meta_block_field', + }, + }, + + edit( { className, setAttributes, attributes } ) { + + function updateBlockValue( blockValue ) { + setAttributes( { blockValue } ); + } + + return ( + <div className={ className }> + <TextControl + label="Meta Block Field" + value={ attributes.blockValue } + onChange={ updateBlockValue } + /> + </div> + ); + }, + + // No information saved to the block + // Data is saved to post meta via attributes + save() { + return null; + } +} ); +``` +{% end %} + +**Important:** Before you test, you need to enqueue your JavaScript file and its dependencies. Note the WordPress packages used above are `wp.element`, `wp.blocks`, and `wp.components`. Each of these need to be included in the array of dependencies. Update the `myguten-meta-block.php` file adding the enqueue function: + +```php +function myguten_enqueue() { + wp_enqueue_script( + 'myguten-script', + plugins_url( 'myguten.js', __FILE__ ), + array( 'wp-blocks', 'wp-element', 'wp-components' ) + ); +} +add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' ); +``` + +You can now edit a draft post and add a Meta Block to the post. You will see your field that you can type a value in. When you save the post, either as a draft or published, the post meta value will be saved too. You can verify by saving and reloading your draft, the form will still be filled in on reload. + +![Meta Block](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block.png) + +You can now use the post meta data in a template, or another block. See next section for [using post meta data](/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md). You could also confirm the data is saved by checking the database table `wp_postmeta` and confirm the new post id contains the new field data. + diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md new file mode 100644 index 0000000000000..6e7d5b92d4d40 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md @@ -0,0 +1,42 @@ +# Use Post Meta Data + +You can use the post meta data stored in the last step in multiple ways. + +## Use Post Meta in PHP + +The first example uses the value from the post meta field and appends it to the end of the post content wrapped in a H4 tag. This method in PHP is unchanged in WordPress 5.0. + +```php +function myguten_content_filter( $content ) { + $value = get_post_meta( get_the_ID(), 'myguten_meta_block_field', true ); + if ( $value ) { + return sprintf( "%s <h4> %s </h4>", $content, esc_html( $value ) ); + } else { + return $content; + } +} +add_filter( 'the_content', 'myguten_content_filter' ); +``` + +## Use Post Meta in Block + +You can also use the post meta data in other blocks. For this example the data is loaded at the end of every Paragraph block when it is rendered, ie. shown to the user. You can replace this for any core or custom block types as needed. + +In PHP, use the [register_block_type](https://developer.wordpress.org/reference/functions/register_block_type/) function to set a callback when the block is rendered to include the meta value. + +```php +function myguten_render_paragraph( $attributes, $content ) { + $value = get_post_meta( get_the_ID(), 'myguten_meta_block_field', true ); + // check value is set before outputting + if ( $value ) { + return sprintf( "%s (%s)", $content, esc_html( $value ) ); + } else { + return $content; + } +} + +register_block_type( 'core/paragraph', array( + 'render_callback' => 'myguten_render_paragraph', +) ); +``` + diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md new file mode 100644 index 0000000000000..b8f41f104b8c8 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md @@ -0,0 +1,20 @@ +# Finishing Touches + +One problem using a meta block is the block is easy for an author to forget, since it requires being added to each post. You solve this by using [block templates](/docs/designers-developers/developers/block-api/block-templates.md). A block template is a predefined list of block items per post type. Templates allow you to specify a default initial state for a post type. + +For this example, you use a template to automatically insert the meta block at the top of a post. + +Add the following code to the `myguten-meta-block.php` file: + +```php +function myguten_register_template() { + $post_type_object = get_post_type_object( 'post' ); + $post_type_object->template = array( + array( 'myguten/meta-block' ), + ); +} +add_action( 'init', 'myguten_register_template' ); +``` + +You can also add other block types in the array, including placeholders, or even lock down a post to a set of specific blocks. Templates are a powerful tool for controlling the editing experience, see the documentation linked above for more. + diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block.png b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block.png new file mode 100644 index 0000000000000..4fe3ebfbc3662 Binary files /dev/null and b/gb-src/docs/designers-developers/developers/tutorials/metabox/meta-block.png differ diff --git a/gb-src/docs/designers-developers/developers/tutorials/metabox/readme.md b/gb-src/docs/designers-developers/developers/tutorials/metabox/readme.md new file mode 100644 index 0000000000000..a1e29fb2beeff --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/metabox/readme.md @@ -0,0 +1,18 @@ +# Meta Boxes + +Prior to the block editor, custom meta boxes were used to extend the editor. With the new editor there are new ways to extend, giving more power to the developer and a better experience for the authors. Porting older custom meta boxes to one of these new methods is encouraged as to create a more unified and consistent experience for those using the editor. + +The new block editor does support most existing meta boxes, see [this backward compatibility article](/docs/designers-developers/developers/backward-compatibility/meta-box.md) for more support details . + +Here are two mini-tutorials for creating similar functionality to meta boxes in the block editor. + +## Use Blocks to Store Meta + +The first method is to use Blocks to store extra data with a post. The data is stored in a post meta field, similar to how meta boxes store information. + +* [Store Post Meta with a Block](/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md) + +## Sidebar Plugin + +If you are interested in working with the post meta outside the editor, check out the [Sidebar Tutorial](/docs/designers-developers/developers/tutorials/plugin-sidebar-0/). + diff --git a/gb-src/docs/designers-developers/developers/tutorials/notices/README.md b/gb-src/docs/designers-developers/developers/tutorials/notices/README.md new file mode 100644 index 0000000000000..56950343a13ce --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/notices/README.md @@ -0,0 +1,80 @@ +# Displaying Notices from Your Plugin or Theme + +Notices are informational UI displayed near the top of admin pages. WordPress core, themes, and plugins all use notices to indicate the result of an action, or to draw the user's attention to necessary information. + +In the classic editor, notices hooked onto the `admin_notices` action can render whatever HTML they'd like. In the block editor, notices are restricted to a more formal API. + +## Notices in the Classic Editor + +In the classic editor, here's an example of the "Post draft updated" notice: + +![Post draft updated in the classic editor](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/notices/classic-editor-notice.png) + +Producing an equivalent "Post draft updated" notice would require code like this: + +```php +/** + * Hook into the 'admin_notices' action to render + * a generic HTML notice. + */ +function myguten_admin_notice() { + $screen = get_current_screen(); + // Only render this notice in the post editor. + if ( ! $screen || 'post' !== $screen->base ) { + return; + } + // Render the notice's HTML. + // Each notice should be wrapped in a <div> + // with a 'notice' class. + echo '<div class="notice notice-success is-dismissible"><p>'; + echo sprintf( __( 'Post draft updated. <a href="%s" target="_blank">Preview post</a>' ), get_preview_post_link() ); + echo '</p></div>'; +}; +add_action( 'admin_notices', 'myguten_admin_notice' ); +``` + +Importantly, the `admin_notices` hook allows a developer to render whatever HTML they'd like. One advantage is that the developer has a great amount of flexibility. The key disadvantage is that arbitrary HTML makes future iterations on notices more difficult, if not possible, because the iterations need to accommodate for arbitrary HTML. This is why the block editor has a formal API. + +## Notices in the Block Editor + +In the block editor, here's an example of the "Post published" notice: + +![Post published in the block editor](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/notices/block-editor-notice.png) + +Producing an equivalent "Post published" notice would require code like this: + +```js +( function( wp ) { + wp.data.dispatch('core/notices').createNotice( + 'success', // Can be one of: success, info, warning, error. + 'Post published.', // Text string to display. + { + isDismissible: true, // Whether the user can dismiss the notice. + // Any actions the user can perform. + actions: [ + { + url: '#', + label: 'View post' + } + ] + } + ); +} )( window.wp ); +``` + +You'll want to use this _Notices Data API_ when producing a notice from within the JavaScript application lifecycle. + +To better understand the specific code example above: + +* `wp` is WordPress global window variable. +* `wp.data` is an object provided by the block editor for accessing the block editor data store. +* `wp.data.dispatch('core/notices')` accesses functionality registered to the block editor data store by the Notices package. +* `createNotice()` is a function offered by the Notices package to register a new notice. The block editor reads from the notice data store in order to know which notices to display. + +Check out the [_Loading JavaScript_](/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md) tutorial for a primer on how to load your custom JavaScript into the block editor. + +## Learn More + +The block editor offers a complete API for generating notices. The official documentation is a great place to review what's possible. + +For a full list of the available actions and selectors, refer to the [Notices Data Handbook](/docs/designers-developers/developers/data/data-core-notices.md) page. diff --git a/gb-src/docs/designers-developers/developers/tutorials/notices/block-editor-notice.png b/gb-src/docs/designers-developers/developers/tutorials/notices/block-editor-notice.png new file mode 100644 index 0000000000000..89bf3e48c0c59 Binary files /dev/null and b/gb-src/docs/designers-developers/developers/tutorials/notices/block-editor-notice.png differ diff --git a/gb-src/docs/designers-developers/developers/tutorials/notices/classic-editor-notice.png b/gb-src/docs/designers-developers/developers/tutorials/notices/classic-editor-notice.png new file mode 100644 index 0000000000000..d96f4aa79a2e4 Binary files /dev/null and b/gb-src/docs/designers-developers/developers/tutorials/notices/classic-editor-notice.png differ diff --git a/gb-src/docs/designers-developers/developers/tutorials/readme.md b/gb-src/docs/designers-developers/developers/tutorials/readme.md new file mode 100644 index 0000000000000..dd063e13e5d64 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/readme.md @@ -0,0 +1,13 @@ +# Tutorials + +* See the [Getting Started with JavaScript Tutorial](/docs/designers-developers/developers/tutorials/javascript/readme.md) to learn about how to use JavaScript within WordPress. + +* The [Blocks Tutorial](/docs/designers-developers/developers/tutorials/block-tutorial/readme.md) is the best place to start to learn more about block creation. + +* See the [Meta Boxes Tutorial](/docs/designers-developers/developers/tutorials/metabox/readme.md) for new ways of extending the editor storing and using post meta data. + +* Check out the [Notices Tutorial](/docs/designers-developers/developers/tutorials/notices/README.md) to learn how to display informational UI at the top of the editor. + +* The [Sidebar Tutorial](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md) will walk you through the steps of creating a sidebar to update data from the `post_meta` table. + +* Learn how to add customized buttons to the toolbar with the [Format API tutorial](/docs/designers-developers/developers/tutorials/format-api/). diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md new file mode 100644 index 0000000000000..f5f270cc9c533 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md @@ -0,0 +1,12 @@ +# Creating a Sidebar for Your Plugin + +This tutorial starts with you having an existing plugin setup and ready to add PHP and JavaScript code. Please, refer to [Getting started with JavaScript](/docs/designers-developers/developers/tutorials/javascript/) tutorial for an introduction to WordPress plugins and how to use JavaScript to extend the block editor. + + In the next sections, you're going to create a custom sidebar for a plugin that contains a text control so the user can update a value that is stored in the `post_meta` table. + +1. [Get a sidebar up and running](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md) +2. [Tweak the sidebar style and add controls](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md) +3. [Register a new meta field](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md) +4. [Initialize the input control with the meta field value](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md) +5. [Update the meta field value when input's content changes](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md) +6. [Finishing touches](/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md) diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md new file mode 100644 index 0000000000000..a241920029ce3 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md @@ -0,0 +1,56 @@ +# Get a Sidebar up and Running + +The first step in the journey is to tell the editor that there is a new plugin that will have its own sidebar. You can do so by using the [registerPlugin](/packages/plugins/README.md), [PluginSidebar](/packages/edit-post/README.md#pluginsidebar), and [createElement](/packages/element/README.md) utilities provided by WordPress, to be found in the `@wordpress/plugins`, `@wordpress/edit-post`, and `@wordpress/element` [packages](/docs/designers-developers/developers/packages.md), respectively. + +Add the following code to a JavaScript file called `plugin-sidebar.js` and save it within your plugin's directory: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + 'Meta field' + ); + }, + } ); +} )( window.wp ); +``` + +For this code to work, those utilities need to be available in the browser, so you tell WordPress to enqueue the packages that include them by introducing `wp-plugins`, `wp-edit-post`, and `wp-element` as dependencies of your script. + +Copy this code to a PHP file within your plugin's directory: + +```php +<?php + +/* +Plugin Name: Sidebar plugin +*/ + +function sidebar_plugin_register() { + wp_register_script( + 'plugin-sidebar-js', + plugins_url( 'plugin-sidebar.js', __FILE__ ), + array( 'wp-plugins', 'wp-edit-post', 'wp-element' ) + ); +} +add_action( 'init', 'sidebar_plugin_register' ); + +function sidebar_plugin_script_enqueue() { + wp_enqueue_script( 'plugin-sidebar-js' ); +} +add_action( 'enqueue_block_editor_assets', 'sidebar_plugin_script_enqueue' ); +``` + +After installing and activating this plugin, there is a new icon resembling a tack in the top-right of the editor. Upon clicking it, the plugin's sidebar will be opened: + +![Sidebar Up and Running](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/sidebar-up-and-running.png) diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md new file mode 100644 index 0000000000000..d919d11c657ae --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md @@ -0,0 +1,97 @@ +# Tweak the sidebar style and add controls + +After the sidebar is up and running, the next step is to fill it up with the necessary components and basic styling. + +To visualize and edit the meta field value you'll use an input component. The `@wordpress/components` package contains many components available for you to reuse, and, specifically, the [TextControl](/packages/components/src/text-control/README.md) is aimed at creating an input field: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( Text, { + label: 'Meta Block Field', + value: 'Initial value', + onChange: function( content ) { + console.log( 'content changed to ', content ); + }, + } ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Update the `plugin-sidebar.js` with this new code. Notice that it uses a new utility called `wp.components` from the `@wordpress/components` package. Go ahead and add it as `wp-components` in the PHP dependencies array. + +It introduces a few changes from the previous section: + +* Added the CSS class `plugin-sidebar-content` to the `div` element to be able to add some styles. +* Substituted the raw _Meta field_ text with a `TextControl` component wrapped within the `div` element. + +With the new CSS class available you can now give the sidebar a bit of breath. Create a new file in your plugin directory called `plugin-sidebar.css` with the following contents: + +```css +.plugin-sidebar-content { + padding: 16px; +} +``` + +For WordPress to load this stylesheet in the editor and front-end, you need to tell it to enqueue it by using the [enqueue_block_editor_assets](https://developer.wordpress.org/reference/hooks/enqueue_block_editor_assets/) action hook. + +After those changes, the PHP code should look like this: + +```php +<?php + +/* +Plugin Name: Sidebar example +*/ + +function sidebar_plugin_register() { + wp_register_script( + 'plugin-sidebar-js', + plugins_url( 'plugin-sidebar.js', __FILE__ ), + array( + 'wp-plugins', + 'wp-edit-post', + 'wp-element', + 'wp-components' + ) + ); + wp_register_style( + 'plugin-sidebar-css', + plugins_url( 'plugin-sidebar.css', __FILE__ ) + ); +} +add_action( 'init', 'sidebar_plugin_register' ); + +function sidebar_plugin_script_enqueue() { + wp_enqueue_script( 'plugin-sidebar-js' ); +} +add_action( 'enqueue_block_editor_assets', 'sidebar_plugin_script_enqueue' ); + +function sidebar_plugin_style_enqueue() { + wp_enqueue_style( 'plugin-sidebar-css' ); +} +add_action( 'enqueue_block_assets', 'sidebar_plugin_style_enqueue' ); +``` + +Reload the editor and open the sidebar: + +![Sidebar with style and controls](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/sidebar-style-and-controls.png) + +With the input control and the styling the sidebar looks nicer. This code doesn't let users to store or retrieve data just yet, so the next steps will focus on how to connect it to the meta block field. diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md new file mode 100644 index 0000000000000..999194e972bae --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md @@ -0,0 +1,21 @@ +# Register the Meta Field + +To work with fields in the `post_meta` table, WordPress has a function called [register_post_meta](https://developer.wordpress.org/reference/functions/register_post_meta/). You're going to use it to register a new field called `sidebar_plugin_meta_block_field`, which will be a single string. Note that this field needs to be available through the [REST API](https://developer.wordpress.org/rest-api/) because that's how the block editor access data. + +Add this to the PHP code, within the `init` callback function: + +```php +register_post_meta( 'post', 'sidebar_plugin_meta_block_field', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', +) ); +``` + +To make sure the field has been loaded, query the block editor [internal data structures](/docs/designers-developers/developers/data/), also known as _stores_. Open your browser's console, and execute this piece of code: + +```js +wp.data.select( 'core/editor' ).getCurrentPost().meta; +``` + +Before adding the `register_post_meta` function to the plugin, this code returns a void array, because WordPress hasn't been told to load any meta field yet. After registering the field, the same code will return an object containing the registered meta field you registered. diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md new file mode 100644 index 0000000000000..bc602e39b2798 --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md @@ -0,0 +1,118 @@ +# Initialize the Input Control + +Now that the field is available in the editor store, it can be surfaced to the UI. The first step will be to extract the input control to a separate function so you can expand its functionality while the code stays clear. + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + + var MetaBlockField = function() { + return el( Text, { + label: 'Meta Block Field', + value: 'Initial value', + onChange: function( content ) { + console.log( 'content changed to ', content ); + }, + } ); + } + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockField ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Now you can focus solely on the `MetaBlockField` component. The goal is to initialize it with the value of `sidebar_plugin_meta_block_field`, but also to keep it updated when that value changes. + +WordPress has [some utilities to work with data](/packages/data/README.md) from the stores. The first you're going to use is [withSelect](/packages/data/README.md#withselect-mapselecttoprops-function-function), whose signature is: + +```js +withSelect( + // a function that takes `select` as input + // and returns an object containing data +)( + // a function that takes the previous data as input + // and returns a component +); +``` + +`withSelect` is used to pass data to other components, and update them when the original data changes. Let's update the code to use it: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + var withSelect = wp.data.withSelect; + + var mapSelectToProps = function( select ) { + return { + metaFieldValue: select( 'core/editor' ) + .getEditedPostAttribute( 'meta' ) + [ 'sidebar_plugin_meta_block_field' ] + } + } + + var MetaBlockField = function( props ) { + return el( Text, { + label: 'Meta Block Field', + value: props.metaFieldValue, + onChange: function( content ) { + console.log( 'content has changed to ', content ); + }, + } ); + } + + var MetaBlockFieldWithData = withSelect( mapSelectToProps )( MetaBlockField ); + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockFieldWithData ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Copy this code to the JavaScript file. Note that it now uses the `wp.data.withSelect` utility to be found in the `@wordpress/data` package. Go ahead and add `wp-data` as a dependency in the PHP script. + +This is how the code changes from the previous section: + +* The `MetaBlockField` function has now a `props` argument as input. It contains the data object returned by the `mapSelectToProps` function, which it uses to initialize its value property. +* The component rendered within the `div` element was also updated, the plugin now uses `MetaBlockFieldWithData`. This will be updated every time the original data changes. +* [getEditedPostAttribute](/docs/designers-developers/developers/data/data-core-editor.md#geteditedpostattribute) is used to retrieve data instead of [getCurrentPost](/docs/designers-developers/developers/data/data-core-editor.md#getcurrentpost) because it returns the most recent values of the post, including user editions that haven't been yet saved. + +Update the code and open the sidebar. The input's content is no longer `Initial value` but a void string. Users can't type values yet, but let's check that the component is updated if the value in the store changes. Open the browser's console, execute + +```js +wp.data.dispatch( 'core/editor' ).editPost( + { meta: { sidebar_plugin_meta_block_field: 'hello world!' } } +); +``` + +and observe how the contents of the input component change! diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md new file mode 100644 index 0000000000000..ac5a9884cae9f --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md @@ -0,0 +1,94 @@ +# Update the Meta Field When the Input's Content Changes + +The last step in the journey is to update the meta field when the input content changes. To do that, you'll use another utility from the `@wordpress/data` package, [withDispatch](/packages/data/README.md#withdispatch-mapdispatchtoprops-function-function). + +`withDispatch` works similarly to `withSelect`. It takes two functions, the first returns an object with data, and the second takes that data object as input and returns a new UI component. Let's see how to use it: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + var withSelect = wp.data.withSelect; + var withDispatch = wp.data.withDispatch; + + var mapSelectToProps = function( select ) { + return { + metaFieldValue: select( 'core/editor' ) + .getEditedPostAttribute( 'meta' ) + [ 'sidebar_plugin_meta_block_field' ], + } + } + + var mapDispatchToProps = function( dispatch ) { + return { + setMetaFieldValue: function( value ) { + dispatch( 'core/editor' ).editPost( + { meta: { sidebar_plugin_meta_block_field: value } } + ); + } + } + } + + var MetaBlockField = function( props ) { + return el( Text, { + label: 'Meta Block Field', + value: props.metaFieldValue, + onChange: function( content ) { + props.setMetaFieldValue( content ); + }, + } ); + } + + var MetaBlockFieldWithData = withSelect( mapSelectToProps )( MetaBlockField ); + var MetaBlockFieldWithDataAndActions = withDispatch( mapDispatchToProps )( MetaBlockFieldWithData ); + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockFieldWithDataAndActions ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Here's how it changed from the previous section: + +* Added a new `mapDispatchToProps` function that will be passed to `withDispatch`. It takes `dispatch` as input and returns an object containing functions to update the internal data structures of the editor. These functions are also known as _actions_. +* By calling `setMetaFieldValue` every time the user types something within the input control, we're effectively updating the editor store on each key stroke. +* The `props` argument to the `MetaBlockField` component contains now the data passed by `mapSelectToProps` and the actions passed by `mapDispatchToProps`. + +Copy this new code to the JavaScript file, load the sidebar and see how the input value gets updated as you type. You may want to check that the internal data structures are updated as well. Type something in the input control, and execute the following instruction in your browser's console: + +```js +wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' )[ + 'sidebar_plugin_meta_block_field' +]; +``` + +The message displayed should be what you typed in the input. + +Now, after doing some changes, hit the "Save draft" button or publish the post. Then, reload the editor page. The browser has now new content, fresh from the database. You want to confirm that what you typed was stored properly in the database, and has been reloaded in the current post data structure. Open the sidebar and make sure the input control is initialized with the last value you typed. + +One last check. At this point, because you haven't edited the input yet, the current post and the edited attributes should be the same. Confirm that by executing this code in your browser's console: + +```js +wp.data.select( 'core/editor' ).getCurrentPost()[ 'meta' ][ + 'sidebar_plugin_meta_block_field' +]; +wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' )[ + 'sidebar_plugin_meta_block_field' +]; +``` + +This is it! You now have a custom sidebar that updates `post_meta` contents. diff --git a/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md new file mode 100644 index 0000000000000..b399c2e59189d --- /dev/null +++ b/gb-src/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md @@ -0,0 +1,198 @@ +# Finishing Touches + +Your JavaScript code now works as expected, here are a few ways to simplify and make it easier to change in the future. + +The first step is to convert the functions `mapSelectToProps` and `mapDispatchToProps` to anonymous functions that get passed directly to `withSelect` and `withData`, respectively: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + var withSelect = wp.data.withSelect; + var withDispatch = wp.data.withDispatch; + + var MetaBlockField = function( props ) { + return el( Text, { + label: 'Meta Block Field', + value: props.metaFieldValue, + onChange: function( content ) { + props.setMetaFieldValue( content ); + }, + } ); + } + + var MetaBlockFieldWithData = withSelect( function( select ) { + return { + metaFieldValue: select( 'core/editor' ) + .getEditedPostAttribute( 'meta' ) + [ 'sidebar_plugin_meta_block_field' ], + } + } )( MetaBlockField ); + + var MetaBlockFieldWithDataAndActions = withDispatch( + function( dispatch ) { + return { + setMetaFieldValue: function( value ) { + dispatch( 'core/editor' ).editPost( + { meta: { sidebar_plugin_meta_block_field: value } } + ); + } + } + } + )( MetaBlockFieldWithData ); + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockFieldWithDataAndActions ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Next, merge `MetaBlockField`, `MetaBlockFieldWithData`, and `MetaBlockFieldWithDataAndActions` into one function called `MetaBlockField` that gets passed to the `div` element. The `@wordpress/compose` package offers an utility to concatenate functions called `compose`. Don't forget adding `wp-compose` to the dependencies array in the PHP script. + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + var withSelect = wp.data.withSelect; + var withDispatch = wp.data.withDispatch; + var compose = wp.compose.compose; + + var MetaBlockField = compose( + withDispatch( function( dispatch ) { + return { + setMetaFieldValue: function( value ) { + dispatch( 'core/editor' ).editPost( + { meta: { sidebar_plugin_meta_block_field: value } } + ); + } + } + } ), + withSelect( function( select ) { + return { + metaFieldValue: select( 'core/editor' ) + .getEditedPostAttribute( 'meta' ) + [ 'sidebar_plugin_meta_block_field' ], + } + } ) + )( function( props ) { + return el( Text, { + label: 'Meta Block Field', + value: props.metaFieldValue, + onChange: function( content ) { + props.setMetaFieldValue( content ); + }, + } ); + } ); + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockField ) + ) + ); + } + } ); +} )( window.wp ); +``` + +Finally, extract the meta field name (`sidebar_plugin_meta_block_field`) from the `withSelect` and `withDispatch` functions to a single place, so it's easier to change in the future. You can leverage the fact that `withSelect` and `withDispatch` first functions can take the props of the UI component they wrap as a second argument. For example: + +```js +// ... +var MetaBlockFieldWithData = withSelect( + function( select, props ) { + console.log( props.metaFieldName ); + } +)( MetaBlockField ); + +// ... + el( + MetaBlockFieldWithData, + { metaFieldName: 'sidebar_plugin_meta_block_field' } + ) +// ... +``` + +Notice how the `metaFieldName` can be accessed within `withSelect`. Let's change the code to take advantage of that: + +```js +( function( wp ) { + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var el = wp.element.createElement; + var Text = wp.components.TextControl; + var withSelect = wp.data.withSelect; + var withDispatch = wp.data.withDispatch; + var compose = wp.compose.compose; + + var MetaBlockField = compose( + withDispatch( function( dispatch, props ) { + return { + setMetaFieldValue: function( value ) { + dispatch( 'core/editor' ).editPost( + { meta: { [ props.fieldName ]: value } } + ); + } + } + } ), + withSelect( function( select, props ) { + return { + metaFieldValue: select( 'core/editor' ) + .getEditedPostAttribute( 'meta' ) + [ props.fieldName ], + } + } ) + )( function( props ) { + return el( Text, { + label: 'Meta Block Field', + value: props.metaFieldValue, + onChange: function( content ) { + props.setMetaFieldValue( content ); + }, + } ); + } ); + + registerPlugin( 'my-plugin-sidebar', { + render: function() { + return el( PluginSidebar, + { + name: 'my-plugin-sidebar', + icon: 'admin-post', + title: 'My plugin sidebar', + }, + el( 'div', + { className: 'plugin-sidebar-content' }, + el( MetaBlockField, + { fieldName: 'sidebar_plugin_meta_block_field' } + ) + ) + ); + } + } ); +} )( window.wp ); +``` + +That's it. You have now a compact version of the original code. Go ahead and add more functionality to your plugin, review other tutorials, or create your next gig! diff --git a/gb-src/docs/designers-developers/faq.md b/gb-src/docs/designers-developers/faq.md new file mode 100644 index 0000000000000..154055b1a6f43 --- /dev/null +++ b/gb-src/docs/designers-developers/faq.md @@ -0,0 +1,382 @@ +# Frequently Asked Questions + +This initial set of questions was created when the Gutenberg project was relatively new, for a more recent set of questions, please see Matt's November 2018 post [WordPress 5.0: A Gutenberg FAQ](https://ma.tt/2018/11/a-gutenberg-faq/). + +## What is Gutenberg? + +“Gutenberg” is the name of the project to create a new editor experience for WordPress. The goal is to create a new post and page editing experience that makes it easy for anyone to create rich post layouts. This was the kickoff goal: + +> The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery. + +Key takeaways include the following points: + +- Authoring richly laid-out posts is a key strength of WordPress. +- By embracing blocks as an interaction paradigm, we can unify multiple different interfaces into one. Instead of learning how to write shortcodes and custom HTML, or pasting URLs to embed media, there's a common, reliable flow for inserting any kind of content. +- “Mystery meat” refers to hidden features in software, features that you have to discover. WordPress already supports a large number of blocks and 30+ embeds, so let's surface them. + +Gutenberg is being developed on [GitHub](https://github.com/WordPress/gutenberg) under the WordPress organization, and you can use it today — [available in the plugin repository](https://wordpress.org/plugins/gutenberg/). + +## When will Gutenberg be merged into WordPress? + +Gutenberg was merged into [WordPress 5.0](https://wordpress.org/news/2018/12/bebo/) that was released in December 2018. + +The editor focus started in early 2017 with the first three months spent designing, planning, prototyping, and testing prototypes, to help us inform how to approach this project. The actual plugin, which you can install from the repository, was launched during WordCamp Europe in June. + +## What are “blocks” and why are we using them? + +The current WordPress editor is an open text window—it’s always been a wonderful blank canvas for writing, but when it comes to building posts and pages with images, multimedia, embedded content from social media, polls, and other elements, it required a mix of different approaches that were not always intuitive: + +- Media library/HTML for images, multimedia and approved files. +- Pasted links for embeds. +- `Shortcodes` for specialized assets from plugins. +- Featured images for the image at the top of a post or page. +- Excerpts for subheadings. +- Widgets for content on the side of a page. + +As we thought about these uses and how to make them obvious and consistent, we began to embrace the concept of “blocks.” All of the above items could be blocks: easy to search and understand, and easy to dynamically shift around the page. The block concept is very powerful, and if designed thoughtfully, can offer an outstanding editing and publishing experience. + +## What is the writing experience like? + +Our goal with Gutenberg is not just to create a seamless post- and page-building experience. We also want to ensure that it provides a seamless writing experience. Though individual paragraphs of text become their own “blocks,” the creation and editing of these blocks are being designed in a way that could be just as simple—if not more so—than the current WordPress editor experience. Here is a brief animation illustrating the Gutenberg writing experience: + +![Typing](https://make.wordpress.org/core/files/2017/10/gutenberg-typing-1_6.gif) + +## Are there Keyboard Shortcuts for Gutenberg? + +Yes. There are a lot! There is a help modal showing all available keyboard shortcuts. + +You can see the whole list going to the top right corner menu of the new editor and clicking on “Keyboard Shortcuts” (or by using the keyboard shortcut <kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> on Linux/Windows and <kbd>⌃</kbd><kbd>⌥</kbd><kbd>H</kbd> on macOS). + +This is the canonical list of keyboard shortcuts: + +### Editor shortcuts + +<table> + <thead> + <tr> + <th>Shortcut description</th> + <th>Linux/Windows shortcut</th> + <th>macOS shortcut</th> + </tr> + </thead> + <tbody> + <tr> + <td>Display keyboard shortcuts.</td> + <td><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>H</kbd></td> + </tr> + <tr> + <td>Save your changes.</td> + <td><kbd>Ctrl</kbd>+<kbd>S</kbd></td> + <td><kbd>⌘</kbd><kbd>S</kbd></td> + </tr> + <tr> + <td>Undo your last changes.</td> + <td><kbd>Ctrl</kbd>+<kbd>Z</kbd></td> + <td><kbd>⌘</kbd><kbd>Z</kbd></td> + </tr> + <tr> + <td>Redo your last undo.</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></td> + <td><kbd>⇧</kbd><kbd>⌘</kbd><kbd>Z</kbd></td> + </tr> + <tr> + <td>Show or hide the settings sidebar.</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>,</kbd></td> + <td><kbd>⇧</kbd><kbd>⌘</kbd><kbd>,</kbd></td> + </tr> + <tr> + <td>Open the block navigation menu.</td> + <td><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>O</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>O</kbd></td> + </tr> + <tr> + <td>Navigate to a the next part of the editor.</td> + <td><kbd>Ctrl</kbd>+<kbd>`</kbd></td> + <td><kbd>⌃</kbd><kbd>`</kbd></td> + </tr> + <tr> + <td>Navigate to the previous part of the editor.</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>`</kbd></td> + <td><kbd>⌃</kbd><kbd>⇧</kbd><kbd>`</kbd></td> + </tr> + <tr> + <td>Navigate to a the next part of the editor (alternative).</td> + <td><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>N</kbd></td> + </tr> + <tr> + <td>Navigate to the previous part of the editor (alternative).</td> + <td><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>P</kbd></td> + </tr> + <tr> + <td>Navigate to the nearest toolbar.</td> + <td><kbd>Alt</kbd>+<kbd>F10</kbd></td> + <td><kbd>⌥</kbd><kbd>F10</kbd></td> + </tr> + <tr> + <td>Switch between Visual Editor and Code Editor.</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd></td> + <td><kbd>⇧</kbd><kbd>⌥</kbd><kbd>⌘</kbd><kbd>M</kbd></td> + </tr> + </tbody> +</table> + +### Selection shortcuts + +<table> + <thead> + <tr> + <th>Shortcut description</th> + <th>Linux/Windows shortcut</th> + <th>macOS shortcut</th> + </tr> + </thead> + <tbody> + <tr> + <td>Select all text when typing. Press again to select all blocks.</td> + <td><kbd>Ctrl</kbd>+<kbd>A</kbd></td> + <td><kbd>⌘</kbd><kbd>A</kbd></td> + </tr> + <tr> + <td>Clear selection.</td> + <td><kbd>Esc</kbd></td> + <td><kbd>Esc</kbd></td> + </tr> + </tbody> +</table> + +### Block shortcuts + +<table> + <thead> + <tr> + <th>Shortcut description</th> + <th>Linux/Windows shortcut</th> + <th>macOS shortcut</th> + </tr> + </thead> + <tbody> + <tr> + <td>Duplicate the selected block(s).</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd></td> + <td><kbd>⇧</kbd><kbd>⌘</kbd><kbd>D</kbd></td> + </tr> + <tr> + <td>Remove the selected block(s).</td> + <td><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>Z</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>Z</kbd></td> + </tr> + <tr> + <td>Insert a new block before the selected block(s).</td> + <td><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd></td> + <td><kbd>⌥</kbd><kbd>⌘</kbd><kbd>T</kbd></td> + </tr> + <tr> + <td>Insert a new block after the selected block(s).</td> + <td><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>Y</kbd></td> + <td><kbd>⌥</kbd><kbd>⌘</kbd><kbd>Y</kbd></td> + </tr> + <tr> + <td>Change the block type after adding a new paragraph.</td> + <td><kbd>/</kbd></td> + <td><kbd>/</kbd></td> + </tr> + </tbody> +</table> + +### Text formatting + +<table> + <thead> + <tr> + <th>Shortcut description</th> + <th>Linux/Windows shortcut</th> + <th>macOS shortcut</th> + </tr> + </thead> + <tbody> + <tr> + <td>Make the selected text bold.</td> + <td><kbd>Ctrl</kbd>+<kbd>B</kbd></td> + <td><kbd>⌘</kbd><kbd>B</kbd></td> + </tr> + <tr> + <td>Make the selected text italic.</td> + <td><kbd>Ctrl</kbd>+<kbd>I</kbd></td> + <td><kbd>⌘</kbd><kbd>I</kbd></td> + </tr> + <tr> + <td>Underline the selected text.</td> + <td><kbd>Ctrl</kbd>+<kbd>U</kbd></td> + <td><kbd>⌘</kbd><kbd>U</kbd></td> + </tr> + <tr> + <td>Convert the selected text into a link.</td> + <td><kbd>Ctrl</kbd>+<kbd>K</kbd></td> + <td><kbd>⌘</kbd><kbd>K</kbd></td> + </tr> + <tr> + <td>Remove a link.</td> + <td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd></td> + <td><kbd>⇧</kbd><kbd>⌘</kbd><kbd>K</kbd></td> + </tr> + <tr> + <td>Add a strikethrough to the selected text.</td> + <td><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>D</kbd></td> + </tr> + <tr> + <td>Display the selected text in a monospaced font.</td> + <td><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>X</kbd></td> + <td><kbd>⌃</kbd><kbd>⌥</kbd><kbd>X</kbd></td> + </tr> + </tbody> +</table> + +Here is a brief animation illustrating how to find and use the keyboard shortcuts: + +![GIF showing how to access keyboard shortcuts](https://make.wordpress.org/community/files/2018/08/gutenberg-keyboard-shortcuts.gif) + +## Is Gutenberg built on top of TinyMCE? + +No. [TinyMCE](https://www.tinymce.com/) is only used for the "Classic" block. + +## What browsers does Gutenberg support? + +Gutenberg works in modern browsers, and Internet Explorer 11. + +Our [list of supported browsers can be found in the Make WordPress handbook](https://make.wordpress.org/core/handbook/best-practices/browser-support/). By “modern browsers” we generally mean the *current and past two versions* of each major browser. + +## How do I make my own block? + +The API for creating blocks is a crucial aspect of the project. We are working on improved documentation and tutorials. Check out the [Creating Block Types](/docs/designers-developers/developers/tutorials/block-tutorial/readme.md) section to get started. + +## Does Gutenberg involve editing posts/pages in the front-end? + +No, we are designing Gutenberg primarily as a replacement for the post and page editing screens. That said, front-end editing is often confused with an editor that looks exactly like the front end. And that is something that Gutenberg will allow as themes customize individual blocks and provide those styles to the editor. Since content is designed to be distributed across so many different experiences—from desktop and mobile to full-text feeds and syndicated article platforms—we believe it's not ideal to create or design posts from just one front-end experience. + +## Given Gutenberg is built in JavaScript, how do old meta boxes (PHP) work? + +We plan to continue supporting existing meta boxes while providing new ways to extend the interface. + +*See:* [Pull request #2804](https://github.com/WordPress/gutenberg/pull/2804) + +## How can plugins extend the Gutenberg UI? + +The main extension point we want to emphasize is creating new blocks. We are still working on how to extend the rest of the UI that is built in JS. We are tracking it here: [Issue #1352](https://github.com/WordPress/gutenberg/issues/1352) + +## Are Custom Post Types still supported? + +Indeed. There are multiple ways in which custom post types can leverage Gutenberg. The plan is to allow them to specify the blocks they support, as well as defining a default block for the post type. It's not currently the case, but if a post type disables the content field, the “advanced” section at the bottom would fill the page. + +## Does Gutenberg support columns? + +Yes, a columns block is available in Gutenberg. + +## Does Gutenberg support nested blocks? + +Yes, it is supported. You can have multiple levels of nesting – blocks within blocks within blocks. + +## Does drag and drop work for rearranging blocks? + +Yes, you can drag and drop blocks to rearrange their order. + +## Can themes _style_ blocks? + +Yes. Blocks can provide their own styles, which themes can add to or override, or they can provide no styles at all and rely fully on what the theme provides. + +## How do block styles work in both the front-end and back-end? + +Blocks are able to provide base structural CSS styles, and themes can add styles on top of this. Some blocks, like a Separator (`<hr/>`), likely don't need any front-end styles, while others, like a Gallery, need a few. + +Other features, like the new _wide_ and _full-wide_ alignment options, are simply CSS classes applied to blocks that offer this alignment. We are looking at how a theme can opt in to this feature, for example using `add_theme_support`. + +*See:* [Theme Support](/docs/designers-developers/developers/themes/theme-support.md) + +## How do editor styles work? + +Regular editor styles are opt-in and work as is in most cases. Themes can also load extra stylesheets by using the following hook: + +```php +function gutenbergtheme_editor_styles() { + wp_enqueue_style( 'gutenbergtheme-blocks-style', get_template_directory_uri() . '/blocks.css'); +} +add_action( 'enqueue_block_editor_assets', 'gutenbergtheme_editor_styles' ); +``` + +*See:* [Editor Styles](/docs/designers-developers/developers/themes/theme-support.md#editor-styles) + +## Should I be concerned that Gutenberg will make my plugin obsolete? + +The goal of Gutenberg is not to put anyone out of business. It's to evolve WordPress so there's more business to be had in the future, for everyone. + +Aside from enabling a rich post and page building experience, a meta goal is to _move WordPress forward_ as a platform. Not only by modernizing the UI, but by modernizing the foundation. + +We realize it's a big change. We also think there will be many new opportunities for plugins. WordPress is likely to ship with a range of basic blocks, but there will be plenty of room for highly tailored premium plugins to augment existing blocks or add new blocks to the mix. + +## Is it possible to opt out of Gutenberg for my site? + +There is a “Classic” block, which is virtually the same as the current editor, except in block form. + +There is also the [Classic Editor plugin](https://wordpress.org/plugins/classic-editor/) which restores the previous editor, see the plugin for more information. The WordPress Core team has committed to supporting the Classic Editor plugin [until December 2021](https://make.wordpress.org/core/2018/11/07/classic-editor-plugin-support-window/). + + +## How do custom TinyMCE buttons work in Gutenberg? + +Custom TinyMCE buttons still work in the “Classic” block, which is a block version of the classic editor you know today. + +(Gutenberg comes with a new universal inserter tool, which gives you access to every block available, searchable, sorted by recency and categories. This inserter tool levels the playing field for every plugin that adds content to the editor, and provides a single interface to learn how to use.) + +## How do shortcodes work in Gutenberg? + +Shortcodes continue to work as they do now. + +However we see the block as an evolution of the `[shortcode]`. Instead of having to type out code, you can use the universal inserter tray to pick a block and get a richer interface for both configuring the block and previewing it. We would recommend people eventually upgrade their shortcodes to be blocks. + +## Should I move shortcodes to content blocks? + +We think so. Blocks are designed to be visually representative of the final look, and they will likely become the expected way in which users will discover and insert content in WordPress. + +## Is Gutenberg made to be properly accessible? + +Accessibility is not an afterthought. Not every aspect of Gutenberg is accessible at the moment. You can check logged issues [here](https://github.com/WordPress/gutenberg/labels/Accessibility). We understand that WordPress is for everyone, and that accessibility is about inclusion. This is a key value for us. + +If you would like to contribute to the accessibility of Gutenberg, we can always use more people to test and contribute. + +## How is data stored? I've seen HTML comments, what is their purpose? + +Our approach—as outlined in [the technical overview introduction](https://make.wordpress.org/core/2017/01/17/editor-technical-overview/)—is to augment the existing data format in a way that doesn’t break the decade-and-a-half-fabric of content WordPress provides. In other terms, this optimizes for a format that prioritizes human readability (the HTML document of the web) and easy-to-render-anywhere over a machine convenient file (JSON in post-meta) that benefits the editing context primarily. + +This also [gives us the flexibility](https://github.com/WordPress/gutenberg/issues/1516) to store those blocks that are inherently separate from the content stream (reusable pieces like widgets or small post type elements) elsewhere, and just keep token references for their placement. + +We suggest you look at the [Gutenberg key concepts](/docs/designers-developers/key-concepts.md) to learn more about how this aspect of the project works. + +## How can I parse the post content back out into blocks in PHP or JS? +In JS: + +```js +var blocks = wp.blocks.parse( postContent ); +``` + +In PHP: + +```php +$blocks = parse_blocks( $post_content ); +``` + +## Why should I start using this once released? +Blocks are likely to become the main way users interact with content. Users are going to be discovering functionality in the new universal inserter tool, with a richer block interface that provides more layout opportunities. + +## What features will be available at launch? What does the post-launch roadmap look like? +As part of the focus on the editor in 2017, a focus on customization and sitebuilding is next. From [the kickoff post](https://make.wordpress.org/core/2017/01/04/focus-tech-and-design-leads/): + +> The customizer will help out the editor at first, then shift to bring those fundamental building blocks into something that could allow customization “outside of the box” of post_content, including sidebars and possibly even an entire theme. + +With the editor, we lay the foundation for bigger things when it comes to page building and customization. + +A lot of features are planned, too many to list here. You can check [Gutenberg's roadmap](https://github.com/WordPress/gutenberg/blob/master/docs/roadmap.md) for more details. + +## WordPress is already the world's most popular publishing platform. Why change the editor at all? +As an open-source project, we believe that it is critical for WordPress to continue to innovate and keep working to make the core experience intuitive and enjoyable for all users. As a community project, Gutenberg has the potential to do just that, and we're excited to pursue this goal together. If you'd like to test, contribute, or offer feedback, [we welcome it here](http://wordpressdotorg.polldaddy.com/s/gutenberg-support). diff --git a/gb-src/docs/designers-developers/glossary.md b/gb-src/docs/designers-developers/glossary.md new file mode 100644 index 0000000000000..a1d2a21fdb4b5 --- /dev/null +++ b/gb-src/docs/designers-developers/glossary.md @@ -0,0 +1,61 @@ +# Glossary + +<dl> +<dt>Attribute sources</dt> +<dd>An object describing the attributes shape of a block. The keys can be named as most appropriate to describe the state of a block type. The value for each key is a function which describes the strategy by which the attribute value should be extracted from the content of a saved post's content. When processed, a new object is created, taking the form of the keys defined in the attribute sources, where each value is the result of the attribute source function.</dd> + +<dt>Attributes</dt> +<dd>The object representation of the current state of a block in post content. When loading a saved post, this is determined by the attribute sources for the block type. These values can change over time during an editing session when the user modifies a block, and are used when determining how to serialize the block.</dd> + +<dt>Block</dt> +<dd>The abstract term used to describe units of markup that, composed together, form the content or layout of a webpage. The idea combines concepts of what in WordPress today we achieve with shortcodes, custom HTML, and embed discovery into a single consistent API and user experience.</dd> + +<dt>Block Categories</dt> +<dd>These are not a WordPress taxonomy, but instead used internally to sort blocks in the Block Inserter.</dd> + +<dt>Block Inserter</dt> +<dd>Primary interface for selecting from the available blocks, triggered by plus icon buttons on Blocks or in the top-left of the editor interface.</dd> + +<dt>Block name</dt> +<dd>A unique identifier for a block type, consisting of a plugin-specific namespace and a short label describing the block's intent. e.g. <code>core/image</code></dd> + +<dt>Block type</dt> +<dd>In contrast with the blocks composing a particular post, a block type describes the blueprint by which any block of that type should behave. So while there may be many images within a post, each behaves consistent with a unified image block type definition.</dd> + +<dt>Classic block</dt> +<dd>A block which embeds the TinyMCE editor as a block, TinyMCE was the base of the previous core editor. Older content created prior to the block editor will be loaded in to a single Classic block.</dd> + +<dt>Dynamic block</dt> +<dd>A type of block where the content of which may change and cannot be determined at the time of saving a post, instead calculated any time the post is shown on the front of a site. These blocks may save fallback content or no content at all in their JavaScript implementation, instead deferring to a PHP block implementation for runtime rendering.</dd> + +<dt>Inspector</dt> +<dd>Deprecated term. See <a href="#settings-sidebar">Settings Sidebar.</a></dd> + +<dt>Post settings</dt> +<dd>A sidebar region containing metadata fields for the post, including scheduling, visibility, terms, and featured image.</dd> + +<dt>RichText</dt> +<dd>A common component enabling rich content editing including bold, italics, hyperlinks, etc.</dd> + +<dt>Reusable block</dt> +<dd>A block that is saved and then can be shared as a reusable, repeatable piece of content.</dd> + +<dt id="settings-sidebar">Settings Sidebar</dt> +<dd>The panel on the right that contains the document and block settings. The sidebar is toggled using the Settings gear icon. Block settings are shown when a block is selected, otherwise document settings are shown.</dd> + +<dt>Serialization</dt> +<dd>The process of converting a block's attributes object into HTML markup, which occurs each time a block is edited.</dd> + +<dt>Static block</dt> +<dd>A type of block where the content of which is known at the time of saving a post. A static block will be saved with HTML markup directly in post content.</dd> + +<dt>TinyMCE</dt> +<dd><a href="https://www.tinymce.com/">TinyMCE</a> is a web-based JavaScript WYSIWYG (What You See Is What You Get) editor.</dd> + +<dt>Toolbar</dt> +<dd>A set of button controls. In the context of a block, usually referring to the toolbar of block controls shown above the selected block.</dd> + +<dt>Template</dt> +<dd> A template is a pre-defined arrangement of blocks, possibly with predefined attributes or placeholder content. You can provide a template for a post type, to give users a starting point when creating a new piece of content, or inside a custom block with the <code>InnerBlocks</code> component. See the templates documentation for more information. See <a href="../../developers/block-api/block-templates.md">templates documentation</a> for more information.</dd> + +</dl> diff --git a/gb-src/docs/designers-developers/key-concepts.md b/gb-src/docs/designers-developers/key-concepts.md new file mode 100644 index 0000000000000..d7f16578f90f8 --- /dev/null +++ b/gb-src/docs/designers-developers/key-concepts.md @@ -0,0 +1,148 @@ +# Key Concepts + +## Blocks + +Blocks are an abstract unit for organizing and composing content, strung together to create content for a webpage. + +Blocks are hierarchical, in that a block can be a child or parent to another block. One example is a two-column Columns block can be the parent block to multiple child blocks in each column. + +If it helps, you can think of blocks as a more graceful shortcode, with rich formatting tools for users to compose content. To this point, there is a new Block Grammar. Distilled, the block grammar is an HTML comment, either a self-closing tag or with a beginning tag and ending tag. In the main tag, depending on the block type and user customizations, there can be a JSON object. This raw form of the block is referred to as serialized. + +```html +<!-- wp:paragraph {"key": "value"} --> +<p>Welcome to the world of blocks.</p> +<!-- /wp:paragraph --> +``` + +Blocks can be static or dynamic. Static blocks contain rendered content and an object of Attributes used to re-render based on changes. Dynamic blocks require server-side data and rendering while the post content is being generated (rendering). + +Each block contains Attributes or configuration settings, which can be sourced from raw HTML in the content, via meta or other customizable origins. + +The Paragraph is the default block. Instead of a new line upon typing return on a keyboard, try to think of it as an empty Paragraph block (type / to trigger an autocompleting Slash Inserter -- /image will pull up Images as well as Instagram embeds). + +Users insert new blocks by clicking the plus button for the Block Inserter, typing / for the Slash Inserter or typing return for a blank Paragraph block. + +Blocks can be duplicated within content using the menu from the block's toolbar or via keyboard shortcut. + +Blocks can be made repeatable, shared across posts and post types and used multiple times in the same post. Changes in one place reflect everywhere that reusable block is used. + +Blocks can be limited or locked-in-place by Templates and custom code. + +#### More on Blocks + +- **Block API** +- **Block Styles** +- **Tutorial: Building A Custom Block** + +## Block Categories + +In the Block Inserter, the accordion-sorted, popup modal that shows a site's available blocks to users, each accordion title is a Block Category, which are either the defaults or customized by developers through Plugins or code. + +## Reusable Blocks + +Reusable Blocks are a way to share a block (or multiple blocks) as a reusable, repeatable piece of content. + +Any edits to a reusable block are made to every usage of a repeatable block. + +Reusable Blocks are stored as a hidden post type, and are dynamic blocks that "ref" or reference the post_id and return the post_content for that wp_block. + +## Templates + +At the core of Gutenberg lies the concept of the block. From a technical point of view, blocks both raise the level of abstraction from a single document to a collection of meaningful elements, and they replace ambiguity—inherent in HTML—with explicit structure. A post in Gutenberg is then a _collection of blocks_. + +To understand how blocks operate at a data-structure level, let's take a small detour to the simile of the printing press of Johannes Gutenberg. In letterpress, a finished page was assembled from individual characters, a test print made in a [galley](https://en.wikipedia.org/wiki/Galley_proof), and then locked into a [chase](https://en.wikipedia.org/wiki/Chase_(printing)) to create a fully formed page. Once printed, there's no need to know whether it was set via individual letters, type slugs from a [linotype machine](https://en.wikipedia.org/wiki/Linotype_machine), or even one giant plate. + +This is true for content blocks. They are the way in which the user creates their content, but they no longer matter once the content is finished. That is, until it needs to be edited. Imagine if the printing press was able to print a page _while_ also including in the page the instructions to generate again the set of movable type required to print it. What we are doing with blocks could be compared to printing invisible marks in the margins so that the printer can make adjustments to an already printed page without needing to set the page again from scratch. + +Content in WordPress is stored as HTML-like text in `post_content`. HTML is a robust document markup format and has been used to describe content as simple as unformatted paragraphs of text and as complex as entire application interfaces. Understanding HTML is not trivial; a significant number of existing documents and tools deal with technically invalid or ambiguous code. This code, even when valid, can be incredibly tricky and complicated to parse – and to understand. + +The main point is to let the machines work at what they are good at, and optimize for the user and the document. The analogy with the printing press can be taken further in that what matters is the printed page, not the arrangement of metal type that originated it. As a matter of fact, the arrangement of type is a pretty inconvenient storage mechanism. The page is both the result _and_ the proper way to store the data. The metal type is just an instrument for publication and editing, but more ephemeral in nature. Exactly as our use of an object tree (e.g. JSON) in the editor. We have the ability to rebuild this structure from the printed page, as if we printed notations in the margins that allows a machine to know which [sorts](<https://en.wikipedia.org/wiki/Sort_(typesetting)>) (metal type) to assemble to recreate the page. + +## Blocks are higher-level than HTML + +Blocks are a helpful tool to describe how to edit content that goes beyond simple text, but they don't carry much meaning _once_ the final page has been generated and is consumed as an HTML document. Even though the end result is HTML in a browser, a "block" connotes more meaning than the HTML it generates. That extra meaning is what enables the rich editing experience, as it allows the application to include tools to help the user craft the content they want. The HTML is augmented with the editing tools. For many blocks, the HTML produced is incidental, and subject to change. Blocks can be powerful and significantly more complex than the HTML they produce. + +The problem an editor like Gutenberg faces is that once things have been transformed into HTML, there's no inherent meaning anymore in the HTML markup from which to construct a specific block interface back. That means the HTML content can be ambiguous: the _same_ markup can correspond to entirely _different_ blocks. One consequence of this fact is that it demonstrates how we have lost meaning when we move down to HTML alone. So there needs to be a reliable way to know a block type without having to understand HTML. + +Additionally, how do we even know this came from our editor? Maybe someone snuck it in by hand when trying to quickly jump in and change the page. The structure of the higher-level meaning is implicit and indistinguishable from the same markup when entered manually. When Gutenberg operates on a block, it knows its type and attributes without inspecting the HTML source. + +## The post dichotomy + +A Gutenberg post is the proper block-aware representation of a post, a collection of semantically consistent descriptions of what each block is and what its essential data is. This representation only ever exists in memory. It is the [chase](<https://en.wikipedia.org/wiki/Chase_(printing)>) in the typesetter's workshop, ever-shifting as sorts are attached and repositioned. + +A Gutenberg post is not the artifact it produces, namely the `post_content`. The latter is the printed page, optimized for the reader, but retaining its invisible markings for later editing. + +Later sections of this document will refer to _Gutenberg post_ and to _blocks_. These are to be assumed to not be the `post_content` or the invisible markings. + +## A Tree of Blocks + +During runtime, blocks are kept in memory, Thus, a Gutenberg post isn't HTML, but a tree of objects and associated attributes. Gutenberg relies on a structure-preserving data model so that the editors and views for specific block types can remain independent from the final rendered HTML. It's a tree similar to how HTML is a tree, though at the top-level it's just a list of nodes—it needs no "root node". + +The tree of objects describes the list of blocks that compose a post. + +```js +[ + { + type: "core/cover-image", + attributes: { + url: "my-hero.jpg", + align: "full", + hasParallax: false, + hasBackgroundDim: true + }, + children: ["Gutenberg posts aren't HTML"] + }, + { + type: "core/paragraph", + children: ["Lately I've been hearing plen…"] + } +]; +``` + +## Serialization and the Purpose of HTML Comments + +Gutenberg's data model, however, is something that lives in memory while editing a post. It's not visible to the page viewer when rendered. Like a printed page has no trace of the structure of the letters that originated it in the press. + +Since the whole WordPress ecosystem has an expectation for receiving HTML when rendering or editing a post, Gutenberg transforms its data model into something that can be saved in `post_content` through serialization. This assures that there's a single source of truth for the content, and that this source remains readable and compatible with all the tools that interact with WordPress content at the present. Were we to store the object tree separately, we would face the risk of `post_content` and the tree getting out of sync and the problem of duplication of data in both places. + +Thus, the serialization process converts the tree into HTML using HTML comments as explicit block delimiters—which can contain the attributes in non-HTML form. This is the act of printing invisible marks on the printed page that leave a trace of the original structured intention. + +This is one end of the process. The other is how to recreate the internal data tree of the collection of blocks whenever a post is to be edited again. A formal grammar defines how the serialized representation of a Gutenberg post should be loaded just as some basic rules define how to turn the tree into an HTML-like string. Gutenberg posts aren't designed to be edited by hand; they aren't designed to be edited as HTML documents because Gutenberg posts aren't HTML in essence. + +They just happen, incidentally, to be stored inside of `post_content` in a way in which they require no transformation in order to be viewable by any legacy system. It's true that loading the stored HTML into a browser without the corresponding machinery might degrade the experience, also if it included dynamic blocks of content: dynamic elements may not load, server-generated content may not appear, interactive content may remain static. However, it at least protects against not being able to view Gutenberg posts on themes and installations which are Gutenberg-unaware, and provides the most accessible way to the content. In other words the post remains mostly intact even if the Gutenberg is not supported on the installation. + +## Delimiters and Parsing Expression Grammar + +We chose instead to try and find a way to keep the formality and explicitness and unambiguity into the existing HTML syntax. Within the HTML there are a number of options: + +Of these options a novel approach was suggested that by storing data in HTML comments we would know that we wouldn't break the rest of the HTML in the document, that browsers should ignore it, and that we could simplify our approach to parsing the document. + +Unique to comments is that they cannot legitimately exist in ambiguous places, such as inside of HTML attributes like `<img alt='data-id="14"'>`. Comments are also quite permissive. Whereas HTML attributes are complicated to parse properly, comments are quite easily described by a leading `<!--` followed by anything except `--` until the first `-->`. This simplicity and permissiveness means that the parser can be implemented in several ways without needing to understand HTML properly and we have the liberty to use more convenient syntax inside of the comment—we only need to escape double-hyphen sequences. We take advantage of this in how we store block attributes: JSON literals inside the comment. + +After running this through the parser we're left with a simple object we can manipulate idiomatically and we don't have to worry about escaping or unescaping the data. It's handled for us through the serialization process. Because the comments are so different from other HTML tags and because we can perform a first-pass to extract the top-level blocks, we don't actually depend on having fully valid HTML! + +This has dramatic implications for how simple and performant we can make our parser. These explicit boundaries also protect damage in a single block from bleeding into other blocks or tarnishing the entire document. It also allows the system to identify unrecognized blocks before rendering them. + +_N.B.:_ The defining aspect of blocks are their semantics and the isolation mechanism they provide; in other words, their identity. On the other hand, where their data is stored is a more liberal aspect. Blocks support more than just static local data (via JSON literals inside the HTML comment or within the block's HTML), and more mechanisms (_e.g._, global blocks or otherwise resorting to storage in complementary `WP_Post` objects) are expected. See [attributes](/docs/designers-developers/developers/block-api/block-attributes.md) for details. + +## The Anatomy of a Serialized Block + +When blocks are saved to the content, after the editing session, its attributes—depending on the nature of the block—are serialized to these explicit comment delimiters. + +```html +<!-- wp:image --> +<figure class="wp-block-image"><img src="source.jpg" alt="" /></figure> +<!-- /wp:image --> +``` + +A purely dynamic block that is to be server rendered before display could look like this: + +```html +<!-- wp:latest-posts {"postsToShow":4,"displayPostDate":true} /--> +``` + +## The Gutenberg Lifecycle + +In summary, the Gutenberg workflow parses the saved document to an in-memory tree of blocks, using token delimiters to help. During editing, all manipulations happen within the block tree. The process ends by serializing the blocks back to the `post_content`. + +The workflow process relies on a serialization/parser pair to persist posts. Hypothetically, the post data structure could be stored using a plugin or retrieved from a remote JSON file to be converted to the block tree. diff --git a/gb-src/docs/final-g-wapuu-black.svg b/gb-src/docs/final-g-wapuu-black.svg new file mode 100644 index 0000000000000..467a13b9f9c0a --- /dev/null +++ b/gb-src/docs/final-g-wapuu-black.svg @@ -0,0 +1,256 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 21.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 720 864" style="enable-background:new 0 0 720 864;" xml:space="preserve"> +<style type="text/css"> + .st0{display:none;} + .st1{display:inline;fill:#191717;} + .st2{fill:#191717;} +</style> +<g id="BACK" class="st0"> + <path class="st1" d="M188.9,575.8c-15.2,0-30.7-3.1-46.5-10.5c-5.9-2.8-8.5-9.8-5.7-15.7c2.8-5.9,9.8-8.5,15.7-5.7 + c57,26.6,111.2-15.2,163.6-55.7c6.1-4.7,12-9.3,17.9-13.7c8-7.6,16.7-7.6,21.9-7.6c1,0,1.9,0.1,2.9,0.4 + c69.1,17.3,143.2,10.5,208.5-19.2c6-2.7,13-0.1,15.7,5.9c2.7,6,0.1,13-5.9,15.7c-69.7,31.6-148.7,39-222.6,20.9 + c-3.1,0-3.5,0.3-4.4,1.3c-0.4,0.4-0.8,0.8-1.3,1.1c-6,4.5-12,9.2-18.2,13.9C289.8,538.4,241.3,575.8,188.9,575.8z"/> + <path class="st1" d="M342.3,425.1c-3.6,0-6.2-0.3-7.2-0.4c-41.8-4.4-42.9-30.4-42.9-31.6c-0.1-25.9,27.7-39.8,44.5-44 + c30.9-9.8,50.1-10.6,60.3-2.4c4.2,3.3,6.2,7.6,6.7,11.3c6.6,20.7-8.8,41.2-19.7,50.6C370.6,422.8,352.4,425.1,342.3,425.1z + M316,392c0.9,2.3,6.1,7.5,21.8,9.1c0.2,0,0.4,0,0.6,0.1c0.1,0,18.1,2.4,28.8-9.2c0.3-0.4,0.7-0.7,1.1-1c4.4-3.8,16.1-16.5,12.9-26 + c-3.4-0.6-13.5-0.9-37.6,6.8c-0.2,0.1-0.5,0.2-0.8,0.2C337.2,373.4,316.9,380.8,316,392z M315.9,392.4c0,0.1,0,0.2,0,0.3 + C315.9,392.6,315.9,392.5,315.9,392.4z M340,360.5L340,360.5L340,360.5z M380.3,359.5C380.3,359.5,380.3,359.5,380.3,359.5 + C380.3,359.5,380.3,359.5,380.3,359.5z"/> + <circle class="st1" cx="544.2" cy="306" r="20.1"/> + <circle class="st1" cx="226.4" cy="382.6" r="20.1"/> + <path class="st1" d="M353.1,531.9c-26.3,0-46.4-16.9-47.4-17.7c-5-4.2-5.6-11.7-1.4-16.7c4.2-5,11.7-5.6,16.7-1.4 + c0.4,0.3,18.1,14.8,37.8,11.6c12.6-2,24.1-11.1,34-27.1c3.5-5.6,10.8-7.3,16.3-3.8c5.6,3.5,7.3,10.8,3.8,16.3 + c-13.7,22.1-30.8,34.9-50.6,38C359.2,531.7,356.1,531.9,353.1,531.9z"/> +</g> +<g id="FRONT"> + <path class="st2" d="M432.4,415.6c-2.5-1.7-5.9-1-7.6,1.5c-9.9,14.9-30.9,15.7-32,15.7c-0.2,0-0.3,0-0.5,0 + c-25.9,0-35.8,22.1-36.2,23c-1.2,2.8,0.1,6,2.8,7.2c0.7,0.3,1.5,0.5,2.2,0.5c2.1,0,4.1-1.2,5-3.3c0.1-0.2,6.9-15.4,24.4-16.4v28.3 + c-0.7,6.1-3.6,10.9-8.7,14.5c-5.3,3.7-12.4,5.6-21.1,5.6c-10.4,0-18.9-3.6-25.2-10.7c-6.4-7.1-9.6-17.2-9.6-30.2l0.1-31.2 + c0.5-11.5,3.6-20.6,9.5-27.1c6.4-7.1,14.8-10.7,25.2-10.7c8.7,0,15.8,1.9,21.1,5.6c5.3,3.7,8.3,8.8,8.8,15.4h0c0,0.2,0,0.5,0,0.7 + c0,3.8,3.1,6.9,6.9,6.9s6.9-3.1,6.9-6.9c0-0.2,0-0.5,0-0.7h0c-1-9.9-5.5-17.7-13.6-23.6c-8.1-5.9-18.2-8.8-30.4-8.8 + c-14.5,0-26.2,4.8-35.1,14.3c-8.4,8.9-12.8,20.6-13.3,35h0v0c0,1-0.1,2-0.1,3l0.1,28.1h-0.1c0,15.9,4.5,28.6,13.4,38.1 + c8.9,9.5,20.6,14.3,35.1,14.3c12.2,0,22.3-2.9,30.4-8.8c7.4-5.4,11.8-12.5,13.3-21.3l0.3-31.4c9.1-2.2,21.5-7.2,29.3-19 + C435.7,420.7,435,417.3,432.4,415.6z"/> + <path class="st2" d="M528.7,94.1h76.7c3.4,0,6.2-2.8,6.2-6.2s-2.8-6.2-6.2-6.2h-76.7c-3.4,0-6.2,2.8-6.2,6.2S525.3,94.1,528.7,94.1 + z"/> + <path class="st2" d="M117.8,94.1h76.7c3.4,0,6.2-2.8,6.2-6.2s-2.8-6.2-6.2-6.2h-76.7c-3.4,0-6.2,2.8-6.2,6.2S114.3,94.1,117.8,94.1 + z"/> + <circle class="st2" cx="609.3" cy="150.1" r="6.2"/> + <circle class="st2" cx="359.8" cy="130.6" r="6.2"/> + <path class="st2" d="M244.8,150.2c0.1,0,0.2,0,0.3,0c23.3,0,33.7-14.6,41.6-28.2c2.6-4.5,12.2-19.5,25.8-23.4 + c16.8-4.9,34.7,2.9,41.7,18.2c1,2.2,3.2,3.5,5.4,3.6c0.1,0,0.2,0,0.3,0c0.1,0,0.2,0,0.4,0c2.2-0.1,4.4-1.4,5.4-3.6 + c7-15.3,24.9-23.1,41.7-18.2c13.6,4,23.2,18.9,25.8,23.4c7.9,13.7,18.3,28.2,41.9,28.2c0,0,0,0,0,0c14.2,0,31-10.6,35.7-30.9 + c3.9-16.8-3.3-32-19-39.7c-3.1-1.5-6.8-0.3-8.3,2.8c-1.5,3.1-0.3,6.8,2.8,8.3c14.9,7.4,13.6,20.5,12.4,25.8 + c-2.4,10.4-11.6,21.4-23.7,21.4c-15.5,0-22.9-7.8-31.2-22.1c-4.1-7-15.5-24-33-29.1c-19.1-5.6-39.3,1.1-50.9,15.8 + c-11.6-14.7-31.8-21.4-50.9-15.8c-17.5,5.1-28.9,22.1-33,29.1c-8.3,14.3-15.7,22.1-30.9,22.1c-0.1,0-0.2,0-0.2,0 + c-13.2-0.1-21.6-9.3-24-18.4c-1.2-4.3-3.7-19,13.1-29c2.9-1.7,3.9-5.5,2.1-8.5c-1.8-2.9-5.5-3.9-8.5-2.1 + c-19.3,11.5-22.3,29.7-18.7,42.9C213.3,139.1,227.8,150.1,244.8,150.2z"/> + <polygon class="st2" points="360,734 360.1,734 360.1,734 360,734 "/> + <circle class="st2" cx="108.1" cy="150.1" r="6.2"/> + <circle class="st2" cx="90.7" cy="88" r="6.2"/> + <circle class="st2" cx="631.3" cy="88" r="6.2"/> + <circle class="st2" cx="664.5" cy="485.3" r="6.2"/> + <circle class="st2" cx="639.6" cy="627.1" r="6.2"/> + <circle class="st2" cx="599.8" cy="644.8" r="6.2"/> + <path class="st2" d="M231.9,111.2c0,9.2,7.5,16.7,16.7,16.7c9.2,0,16.7-7.5,16.7-16.7s-7.5-16.7-16.7-16.7 + C239.4,94.4,231.9,101.9,231.9,111.2z M248.6,106.8c2.4,0,4.4,2,4.4,4.4c0,2.4-2,4.4-4.4,4.4c-2.4,0-4.4-2-4.4-4.4 + C244.2,108.8,246.2,106.8,248.6,106.8z"/> + <path class="st2" d="M473.6,127.9c9.2,0,16.7-7.5,16.7-16.7s-7.5-16.7-16.7-16.7s-16.7,7.5-16.7,16.7S464.4,127.9,473.6,127.9z + M473.6,106.8c2.4,0,4.4,2,4.4,4.4c0,2.4-2,4.4-4.4,4.4c-2.4,0-4.4-2-4.4-4.4C469.2,108.8,471.2,106.8,473.6,106.8z"/> + <path class="st2" d="M376.7,121.6c-3.2,1.2-4.8,4.7-3.6,7.9c1.2,3.2,4.7,4.8,7.9,3.7c1-0.4,25-8.4,43.4,20.2 + c1.2,1.8,3.2,2.8,5.2,2.8c1.1,0,2.3-0.3,3.3-1c2.9-1.8,3.7-5.7,1.9-8.5C415.6,117,388.4,117.3,376.7,121.6z"/> + <path class="st2" d="M343.3,121.6c-0.9-0.3-9.1-3.2-20.3-1.5c-10.4,1.5-25.3,7.4-37.7,26.7c-1.8,2.9-1,6.7,1.9,8.5 + c1,0.7,2.2,1,3.3,1c2,0,4-1,5.2-2.8c18.3-28.6,42.3-20.6,43.4-20.2c3.2,1.2,6.7-0.5,7.9-3.7C348.2,126.4,346.5,122.8,343.3,121.6z" + /> + <path class="st2" d="M627.8,138.5l30.1-34.2v49.3c0,3.4,2.8,6.2,6.2,6.2s6.2-2.8,6.2-6.2V88c0-2.6-1.6-4.9-4-5.8 + c-2.4-0.9-5.1-0.2-6.8,1.7l-40.9,46.5c-2.3,2.6-2,6.5,0.6,8.7C621.6,141.4,625.5,141.1,627.8,138.5z"/> + <path class="st2" d="M56.8,159.9c3.4,0,6.2-2.8,6.2-6.2v-49.3l30.1,34.2c2.3,2.6,6.2,2.8,8.7,0.6c2.6-2.3,2.8-6.2,0.6-8.7 + L61.5,83.9c-1.7-1.9-4.4-2.6-6.8-1.7c-2.4,0.9-4,3.2-4,5.8v65.7C50.7,157.1,53.4,159.9,56.8,159.9z"/> + <path class="st2" d="M493.4,166c-2.9-0.2-5.6,1.8-6.3,4.6c-0.7,2.9,0.7,5.9,3.3,7.1c9.8,4.6,18.7,6.4,26.7,6.4 + c15.3,0,27.5-6.7,36-13.7c13.6-11.2,20.6-25.3,20.9-25.9c1.4-2.9,0.4-6.4-2.4-8c-6.3-3.7-17.2-6-33.8,4.1 + c-8.3,5.1-14.7,11.2-15.8,12.3C506.8,166.3,494.1,166,493.4,166z M530.3,162.1c0.1-0.1,0.2-0.2,0.3-0.3c4.7-4.7,18-15.6,28.4-16.1 + c-3,4.3-7.5,10.1-13.6,15.2c-9,7.4-18.6,11-28.8,10.8C520.8,169.5,525.5,166.3,530.3,162.1z"/> + <path class="st2" d="M151.4,136.5c-2.8,1.6-3.8,5.1-2.4,8c0.3,0.6,7.3,14.7,20.9,25.9c8.5,7,20.6,13.7,36,13.7 + c8.1,0,17-1.8,26.7-6.4c2.7-1.3,4-4.2,3.3-7.1c-0.7-2.9-3.3-4.8-6.3-4.7c-0.5,0-13.3,0.4-28.5-13c-1.1-1.1-7.5-7.2-15.8-12.3 + C168.7,130.6,157.7,132.9,151.4,136.5z M192.8,162.1c4.7,4.2,9.4,7.3,13.7,9.6c-21.6,0.2-35.9-16.3-42.4-26 + c10.4,0.5,23.7,11.4,28.4,16.1C192.6,161.9,192.7,162,192.8,162.1z"/> + <path class="st2" d="M671.8,664.6c-6.2-5.2-13-8.2-19.3-9.9c2.4-1.8,4.7-3.9,6.7-6.2c15.3-18,15.4-44.5,0.2-61.7 + c-2.3-2.6-6.2-2.8-8.7-0.5c-2.6,2.3-2.8,6.2-0.5,8.7c11.1,12.6,10.9,32.1-0.4,45.5c-9.2,10.8-26.7,13.4-31.9,14 + c-15.7,1.7-33.3,5.3-42.8,26.8c-1.5,3.3-2.4,7-2.6,11c-2.2-0.7-4.6-1.1-7-1.1c-12.5,0-22.7,10.2-22.7,22.7 + c0,8.6,4.8,16.1,11.9,19.9c-2.1,1.9-4.1,4-5.9,6.4c-21-28.3-54.6-46.1-91.6-46.1c-5.3,0-10.6,0.4-15.7,1.1 + c30.4-9.3,58.7-23.8,84.5-43l0,0c33.3-24.9,61-57.4,80-93.9c20-38.4,30.1-79.9,30.1-123.1c0-0.3,0-0.5,0-0.8c0-0.1,0-0.1,0-0.2 + c0-0.1,0-0.2,0-0.3c-0.2-35.1-8-66.7-19.1-93.9c4.1,0.8,8,1.1,11.5,1.1c25.9,0,37.3-17.8,38.5-19.9h0c5.6-9.4,6.5-20.8,3.3-31.1 + c0.1-0.3,0.1-0.7,0.1-1.1V176.3c0-3.4-2.8-6.2-6.2-6.2s-6.2,2.8-6.2,6.2V271c-10.5-9.1-25.7-10.1-32-10.1 + c-12.2,0.1-19.8-1.8-25.4-11.4c-4.7-8.1-2-16.5,2.7-21.3c5.9-6,14.3-6.8,22.5-2.3c3,1.6,6.8,0.6,8.4-2.4c1.6-3,0.6-6.8-2.4-8.4 + c-13-7.2-27.6-5.4-37.3,4.4c-9.6,9.8-11.4,24.3-4.6,36.1c8.9,15.3,22.4,17.5,35.7,17.5c0.2,0,0.3,0,0.5,0c4,0,16.9,0.6,24.3,7.6 + c9.6,9.1,12.1,23.4,5.8,34c0,0-0.1,0.1-0.1,0.2c-0.5,0.9-12.4,21.1-45.6,10.5c-10-21.2-21.6-39.2-32.4-53.6l-4.9,3.7l4.9-3.8 + l-2-2.6c-21.3-35.7,3.3-73.7,16.1-89.8l7.2-8.2c0,0,0,0,0,0c0,0,0,0,0,0l0,0c2.2-2.6,2-6.5-0.6-8.7c-2.5-2.1-6.1-2-8.4,0.3l0,0 + c-0.2,0.2-1.3,1.3-3,3.2c-11.1,11.3-54.5,51.3-96.6,30.4l-1.2-0.8l-0.6-0.4c-37.7-20.3-74.9-32-110.4-35 + c-4.6-10.5-10.7-18.5-18.4-18.5c-7.8,0-13.9,8.1-18.6,18.8c-49.7,2.2-111.5,34.3-114.1,35.6c-42.3,21.3-85.9-18.9-97-30.2 + c-1.7-1.9-2.9-3-3-3.2l0,0c-2.3-2.3-5.9-2.4-8.4-0.3c-2.6,2.2-2.8,6.1-0.6,8.7l0,0c0,0,0,0,0,0c0,0,0,0,0,0l7.7,8.7 + c4.5,5.6,10.2,13.7,15,23.4c12,24.3,12.2,46.4,0.7,65.8c-14.3,17.8-25.9,36.5-34.8,56.2c-33.5,10.9-45.5-9.5-46-10.4 + c0-0.1-0.1-0.1-0.1-0.2c-6.3-10.6-3.8-24.9,5.8-34c7.4-7,20.3-7.6,24.3-7.6c0,0,0.1,0,0.1,0c13.4,0,27.2-2.1,36.1-17.5 + c6.8-11.8,5-26.4-4.6-36.1c-9.7-9.9-24.3-11.6-37.3-4.4c-3,1.6-4.1,5.4-2.4,8.4c1.6,3,5.4,4.1,8.4,2.4c8.3-4.5,16.7-3.7,22.5,2.3 + c4.7,4.8,7.3,13.2,2.7,21.3c-5.5,9.5-13,11.4-25,11.4c-0.1,0-0.2,0-0.4,0c-5.5,0-18.3,0.8-28.5,7.4v-90.3c0-3.4-2.8-6.2-6.2-6.2 + s-6.2,2.8-6.2,6.2v104c-6.8,12.2-7.2,27.1-0.2,39.1c0.7,1.3,5,8.5,14.6,13.9c5.5,3.1,13.4,6,23.9,6c3.8,0,8-0.4,12.5-1.3 + c-10.8,29-16.2,60.2-16.4,94.2c0,0.1,0,0.1,0,0.2c0,0,0,0.1,0,0.1c0,0.3,0,0.6,0,0.8c0,61.4,21.6,121.7,60.8,169.8 + c34.7,42.6,81.9,74.2,134.2,90.3c-5.5-0.8-11.1-1.2-16.7-1.2c-36.7,0-70,17.5-91,45.4c-1.7-2.2-3.6-4.3-5.7-6.1 + c6.6-4,11.1-11.2,11.1-19.5c0-12.5-10.2-22.7-22.7-22.7c-2.3,0-4.5,0.4-6.6,1c-0.3-3.9-1.2-7.6-2.6-10.9 + c-9.6-21.5-27.1-25.1-42.8-26.8c-5.2-0.6-22.7-3.2-31.9-14C59.2,627,59,607.5,70.1,594.9c2.3-2.6,2-6.5-0.5-8.7 + c-2.6-2.3-6.5-2-8.7,0.5c-15.1,17.2-15,43.7,0.2,61.7c2,2.4,4.3,4.4,6.7,6.2c-6.3,1.7-13.1,4.8-19.3,9.9 + c-14.7,12.1-22.2,32.3-22.4,59.8c-0.5,2.5,0.6,5.1,2.8,6.5c1,0.6,2.1,0.9,3.2,0.9c0.4,0,0.9-0.1,1.3-0.2c0,0,0.1,0,0.1,0 + c0.2,0,0.3-0.1,0.5-0.2c0.3-0.1,0.7-0.2,1-0.4c0.1,0,0.1-0.1,0.2-0.1c1-0.6,1.9-1.4,2.5-2.4c2.4-3.1,12.3-7,20.5-8.7 + c32.1-7,38.8-28.7,37.8-39.1c-0.3-3.4-3.3-5.9-6.7-5.6c-3.4,0.3-5.9,3.3-5.6,6.7c0,0.1,0.1,1.9-0.5,4.6c-1.5,6.2-7,16.9-27.6,21.4 + c-0.1,0-0.1,0-0.2,0c-2.4,0.5-9.6,2.2-16.3,5.3c1.8-17.8,7.5-30.8,17.2-38.9c14.9-12.4,34.6-8.9,34.8-8.8c0.7,0.1,1.3,0.1,1.9,0 + c3.3,0.7,6.1,1.1,8.2,1.3c16.5,1.8,26.6,5.4,32.9,19.5c4.9,11.1-1.4,23.9-9.9,30.3c-4.3,3.3-15.8,9.8-28.7-0.8 + c-2.6-2.2-6.5-1.8-8.7,0.8c-2.2,2.6-1.8,6.5,0.8,8.7c5.8,4.8,12.3,7.6,19,8.4c-8.6,7-14.4,15.8-17,20.4 + c-8.4,14.5-15.2,22.3-26.5,22.4V733c0-3.4-2.8-6.2-6.2-6.2s-6.2,2.8-6.2,6.2v50.2c0,2.5,1.5,4.6,3.6,5.6c1.1,0.8,2.4,1.4,3.8,1.4 + h144c2.2,0,4-1.1,5.2-2.8c0.8-0.8,1.5-1.8,1.8-3c5.7-22.1,25.6-37.5,48.4-37.5c20.4,0,38.5,12.4,46.2,30.9h-13.2 + c-5.4-13.3-18.4-22.2-33.1-22.2c-16.1,0-30.3,10.9-34.6,26.4c-0.9,3.3,1.1,6.7,4.3,7.6c3.3,0.9,6.7-1.1,7.6-4.3 + c2.8-10.2,12.1-17.3,22.6-17.3c7.8,0,14.8,3.8,19.1,9.8h-3.6c-3.4,0-6.2,2.8-6.2,6.2c0,3.4,2.8,6.2,6.2,6.2h12.5 + c0.2,0,0.5,0.1,0.7,0.1c0.3,0,0.6,0,0.8-0.1h24.8c3.4,0,6.2-2.8,6.2-6.2c0-0.3,0-0.6-0.1-0.9c0.1-0.8,0.1-1.7-0.1-2.6 + c-7.3-27.1-32.1-46-60.2-46c-27.2,0-51.1,17.6-59.4,43.3h-32.3c2.6-8.3,6.1-16,10.5-23.2c0.3-0.4,0.5-0.8,0.8-1.2 + c18.3-28.7,50.3-47.1,85.8-47.1c21,0,41.2,6.4,58.3,18.4c0.5,0.3,1,0.6,1.5,0.7c2.6,3.1,5.2,6.4,7.7,9.8c0.6,0.8,1.1,1.6,1.7,2.4 + c-0.6,12.7,6.3,26,20.6,39.6c0.5,1.5,1,2.8,1.3,3.8v1.3c0,3.4,2.8,6.2,6.2,6.2c2.9,0,5.2-1.9,5.9-4.6l0,0c0-0.1,0.9-3.1,2.7-8.1 + c12.9-12.6,19.3-24.9,19.2-36.8c0.8-1.2,1.7-2.5,2.6-3.7c2.8-3.8,5.7-7.5,8.7-11c17-11.8,36.9-18,57.7-18c44.8,0,84,29.2,97,71.5 + h-32.3c-8.2-25.7-32.1-43.3-59.4-43.3c-28.1,0-52.8,18.9-60.2,46c-0.3,1-0.3,2,0,3c0,0.2,0,0.3,0,0.5c0,3.4,2.8,6.2,6.2,6.2h54.2 + c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2h-19.7c4.3-6,11.3-9.8,19.1-9.8c10.7,0,20,7.2,22.7,17.6c0.7,2.8,3.2,4.6,6,4.6 + c0.5,0,1-0.1,1.5-0.2c3.3-0.8,5.3-4.2,4.5-7.5c-4.1-15.8-18.3-26.9-34.7-26.9c-14.6,0-27.6,8.9-33.1,22.2h-12.5 + c7.6-18.5,25.8-30.9,46.2-30.9c22.8,0,42.7,15.4,48.4,37.5c0.1,0.5,0.4,1,0.6,1.5c0.8,2.5,3.1,4.3,5.9,4.3H664 + c3.4,0,6.2-2.8,6.2-6.2c0-0.1,0-0.3,0-0.4c0-0.1,0-0.3,0-0.4V782c0.1-0.6,0.1-1.2,0.1-1.8c0-0.1,0-0.2-0.1-0.3V733 + c0-3.4-2.8-6.2-6.2-6.2s-6.2,2.8-6.2,6.2v42c-10.4-0.8-16.9-8.5-24.9-22.3c-2.6-4.4-8-12.7-16-19.5c5.5-1.2,10.8-3.8,15.7-7.9 + c2.6-2.2,3-6.1,0.8-8.7c-2.2-2.6-6.1-3-8.7-0.8c-12.8,10.6-24.3,4.1-28.7,0.8c-8.5-6.4-14.8-19.3-9.9-30.3 + c6.3-14.1,16.4-17.8,32.9-19.5c2.1-0.2,4.9-0.6,8.2-1.3c0.6,0.1,1.3,0.1,2,0c0.2,0,19.7-3.6,34.7,8.8c9.8,8.1,15.6,21.1,17.3,39 + c-6.8-3.2-14.1-4.9-16.4-5.4c-0.1,0-0.1,0-0.2,0c-20.5-4.5-26.1-15.1-27.6-21.3c-0.6-2.6-0.6-4.4-0.5-4.6c0.3-3.4-2.2-6.4-5.6-6.7 + c-3.4-0.3-6.4,2.2-6.7,5.6c-1,10.5,5.7,32.1,37.9,39.1c8.3,1.8,18.8,5.9,20.7,9c0,0,0,0.1,0.1,0.1c0.3,0.4,0.6,0.8,0.9,1.1 + c0.1,0.1,0.2,0.1,0.3,0.2c0.3,0.2,0.6,0.5,0.9,0.6c0.1,0.1,0.3,0.2,0.4,0.2c0.3,0.2,0.6,0.3,0.9,0.4c0.1,0,0.3,0.1,0.4,0.1 + c0.4,0.1,0.9,0.2,1.4,0.2c0,0,0,0,0,0c0.4,0,0.7,0,1.1-0.1c0.1,0,0.2,0,0.3-0.1c0.3-0.1,0.6-0.2,1-0.3c0,0,0.1,0,0.1,0 + c0.3-0.1,0.5-0.3,0.8-0.4c0,0,0.1-0.1,0.1-0.1c0.2-0.1,0.4-0.3,0.5-0.4c0.1-0.1,0.3-0.2,0.4-0.4c0.1-0.1,0.3-0.3,0.4-0.4 + c0.1-0.2,0.3-0.3,0.4-0.5c0.1-0.2,0.2-0.3,0.3-0.5c0.1-0.2,0.2-0.3,0.3-0.5c0.1-0.2,0.1-0.4,0.2-0.6c0.1-0.2,0.1-0.4,0.2-0.6 + c0-0.2,0.1-0.4,0.1-0.5c0-0.2,0.1-0.4,0.1-0.7c0,0,0-0.1,0-0.1C694.3,697.4,686.7,676.9,671.8,664.6z M152.9,777.7H87.8 + c5.5-5.6,9.4-12.3,12.4-17.5c2.6-4.5,12.2-19.5,25.8-23.4c16-4.7,31,0.8,38.4,13.5C159.5,758.8,155.6,768,152.9,777.7z M154.6,724 + c-5.7,0-10.3-4.6-10.3-10.3c0-5.7,4.6-10.3,10.3-10.3s10.3,4.6,10.3,10.3C164.9,719.4,160.2,724,154.6,724z M521.2,645.9 + L521.2,645.9l0.9,1.1L521.2,645.9z M521.4,639.7c-58.4-6.8-100.6,18.3-125.8,40.5c-10.9,9.6-19.5,19.5-26,27.9 + c-1.2-0.8-2.3-1.6-3.3-2.1v-17.6c47.3-47.5,94.3-61.7,125.5-65.2c20.7-2.3,37.4-0.4,47.4,1.5C533.5,630,527.6,635,521.4,639.7z + M594.7,552.5c-6,11.5-12.9,22.5-20.5,33.1c-7.9-2.5-17.8-4.8-29.7-5.9c26.5-33.6,43.3-73.7,48.7-116c8.6-1,20.5-4.7,29.6-10.6 + C620.4,487.8,611,521.2,594.7,552.5z M552.6,440.9l7.2-0.3c2.4,10.7,10.5,19.3,21.1,22.2c-5.6,42.8-23.4,83.2-51.8,116.2 + c-13.7,0-29.6,1.6-47.6,5.5C523.5,550.3,550.9,498.8,552.6,440.9z M435.1,598.9c-4,1.6-6.8,2.9-8.2,3.5 + c-16.6,6.6-34.4,10.9-53,12.3l165.9-165.9C534.8,515.6,493.2,572.2,435.1,598.9z M623.4,434.2c-0.2,5.6-15.5,14.7-29,16.9 + c-1.9,0.3-3.8,0.5-5.6,0.5c-2.4,0-4.7-0.5-6.8-1.4c-6.3-2.7-10.7-8.9-10.7-16.1c0-7.2,4.4-13.4,10.7-16.1c2.1-0.9,4.4-1.4,6.8-1.4 + c1.8,0,3.7,0.2,5.6,0.5c13.5,2.2,28.7,11.3,29,16.8C623.4,434,623.4,434.1,623.4,434.2z M565.5,276l2.6,3.5v0 + c22.7,30.3,49.3,77.2,54.4,135.6c-9-5.8-20.8-9.5-29.3-10.5C586.8,354,564.3,308.3,531,273c1.4-2.9,2.2-6.2,2.2-9.6 + c0-4.6-1.4-8.9-3.7-12.4l28.4-32.4C552.5,239.2,555.1,258.7,565.5,276z M482.6,205.9l1.3,0.9l0.7,0.4c9.9,5,20.5,7.5,31.5,7.5 + c11.6,0,23.7-2.8,35.9-8.4l-32,36.5c-2.9-1.4-6.1-2.1-9.6-2.1c-4.5,0-8.6,1.3-12.1,3.5c-31.5-23-68.8-38.3-109.4-43.3 + c-1-7.3-3-17.7-6-27.9C415,176.6,448.5,187.6,482.6,205.9z M520.8,263.4c0,4.5-3,8.4-7.1,9.8c-1,0.3-2.1,0.5-3.2,0.5 + c-0.3,0-0.7,0-1,0c-4.6-0.4-8.3-3.9-9.1-8.3c-0.1-0.6-0.2-1.3-0.2-1.9c0-0.7,0.1-1.3,0.2-2c0.9-4.5,4.7-8,9.4-8.3 + c0.2,0,0.5,0,0.7,0c0.2,0,0.4,0,0.6,0c4.6,0.3,8.5,3.6,9.5,8c0.1,0.6,0.2,1.1,0.2,1.7C520.7,263,520.8,263.2,520.8,263.4z + M389.4,213.5c37.2,4.9,71.6,19.1,100.7,40.1c-1.5,3-2.3,6.3-2.3,9.9c0,4.5,1.3,8.6,3.6,12.2l-49.8,56.8c-2.3,2.6-2,6.5,0.6,8.7 + c1.2,1,2.6,1.5,4.1,1.5c1.7,0,3.4-0.7,4.7-2.1l49.7-56.7c3,1.5,6.3,2.3,9.9,2.3c4.6,0,8.8-1.4,12.3-3.7c31,33.2,51.9,75.8,58.1,123 + c-10.7,3-19,11.8-21.2,22.8l-7.1,0.3c-1.5-45.7-19.2-89.3-50.1-123.2c-2.3-2.5-6.2-2.7-8.7-0.4c-2.5,2.3-2.7,6.2-0.4,8.7 + c27.2,29.8,43.4,67.6,46.5,107.6L473.5,355c-2.4-2.4-6.3-2.4-8.7,0c-2.4,2.4-2.4,6.3,0,8.7l71.4,71.4l-176,176l-176-176l70.8-70.8 + c2.4-2.4,2.4-6.3,0-8.7c-2.4-2.4-6.3-2.4-8.7,0l-65.7,65.7c3.5-45.5,23.9-86.4,55-116.2l31,35.4c1.2,1.4,2.9,2.1,4.7,2.1 + c1.4,0,2.9-0.5,4.1-1.5c2.6-2.3,2.8-6.2,0.6-8.7l-31.1-35.5c28-23.4,63.1-38.5,101.6-41.4l-52.8,52.3c-2.4,2.4-2.4,6.3,0,8.7 + c1.2,1.2,2.8,1.8,4.4,1.8c1.6,0,3.1-0.6,4.4-1.8l58-57.4l56.3,56.3c2.4,2.4,6.3,2.4,8.7,0c2.4-2.4,2.4-6.3,0-8.7l-51.2-51.2 + c31.7,2.4,61.9,13,88.2,31.1c2.8,1.9,6.7,1.2,8.6-1.6c1.9-2.8,1.2-6.7-1.6-8.6c-30.4-20.9-65.8-32.5-102.8-33.6v-4.4 + C378.3,235.6,387.6,225.7,389.4,213.5z M208.7,287.8c3.5,0,6.8-0.8,9.7-2.2l8.9,10.2c-35.3,33.7-57.8,80.6-59.6,132.7l-5-0.3 + c-2.4-11.7-11.5-20.9-23.2-23.3c6.1-46.3,26.5-88.1,56.5-120.9C199.7,286.4,204,287.8,208.7,287.8z M198.4,265.2 + c0-0.5,0-0.9,0.1-1.4c0-0.3,0.1-0.5,0.1-0.8c0.9-4.4,4.7-7.8,9.3-8.1c0.2,0,0.4,0,0.5,0c0.1,0,0.1,0,0.2,0c4.7,0,8.7,3.2,9.9,7.6 + c0.2,0.9,0.4,1.8,0.4,2.7c0,0.7-0.1,1.4-0.2,2.1c-0.9,4.4-4.7,7.8-9.3,8.1c-0.3,0-0.5,0-0.8,0c-1.3,0-2.6-0.3-3.7-0.7 + C201.2,273.3,198.4,269.5,198.4,265.2z M162.6,440.6l5.1,0.3c1.7,58,29.2,109.6,71.3,143.8c-18.1-4-34-5.6-47.8-5.7 + c-28.2-32.9-46.1-73.1-51.7-115.7C151,460.9,160.1,452,162.6,440.6z M180.5,448.8l165.9,165.9C258,608,187.2,537.2,180.5,448.8z + M343.2,199.8c2.7-18.7,11.9-43.7,16.7-45.7c5.5,2.1,14,28,16.6,45.6c0.5,3.5,0.8,6.7,0.8,9.3c0,1.1-0.1,2.1-0.3,3.1 + c-1.5,8.1-8.6,14.3-17.2,14.3c-8.6,0-15.7-6.2-17.2-14.3c-0.2-1-0.3-2.1-0.3-3.1C342.4,206.3,342.7,203.2,343.2,199.8z M354,238.3 + v4.3c-44.6,1.4-85.4,18.1-117.4,44.9l-8.8-10.1c2.3-3.5,3.7-7.8,3.7-12.3c0-3.8-1-7.4-2.6-10.5c29.3-21.5,64-36,101.7-41.1 + C332.3,225.9,341.8,235.9,354,238.3z M201.3,214.7c11,0,21.6-2.5,31.6-7.5c0.6-0.3,57.7-30,103.9-34c-3,10.1-5,20.6-6,27.8 + c-41.1,5.1-79,21-110.7,44.6c-3.4-2-7.2-3.1-11.4-3.1c-3.5,0-6.8,0.8-9.7,2.2l-33.6-38.3C177.7,211.9,189.7,214.7,201.3,214.7z + M151.4,276.6l0.5-0.7c10.4-17.2,13-36.8,7.8-57.4l30,34.3c-2.3,3.5-3.7,7.8-3.7,12.3c0,3.3,0.7,6.4,2,9.3 + c-32.7,35.2-54.7,80.4-61,130.4c-8.9,1.4-20.7,5.2-29.6,10.9C100.9,362.9,118.6,317.2,151.4,276.6z M96.9,434 + c0.2-5.8,16.3-13.8,29-16.4c2.8-0.6,5.4-0.9,7.6-0.9c1.7,0,3.2,0.2,4.8,0.7c7.3,2.1,12.7,8.8,12.7,16.8c0,8-5.4,14.7-12.7,16.8 + c-1.5,0.4-3.1,0.7-4.8,0.7c-2.3,0-4.9-0.3-7.6-0.9c-12.7-2.7-28.8-10.7-29-16.5C96.9,434.1,96.9,434.1,96.9,434z M97.5,452.4 + c8.9,5.8,20.8,9.6,29.6,11c5.3,42.4,22.1,82.6,48.7,116.2c-11.9,1.1-21.8,3.3-29.8,5.8C117.3,546,100.7,500.5,97.5,452.4z + M154.9,597c-0.3-0.4-0.6-0.7-0.9-1.1c30.1-8.3,63-3.9,86.1,1.6c27.9,6.7,48.3,16.4,48.5,16.5l0.1-0.2 + c22.1,8.8,46.2,13.7,71.4,13.7c25.1,0,49.1-4.8,71.1-13.6l0,0c0,0,0.2-0.1,0.6-0.3c2.6-1.1,5.3-2.2,7.8-3.4 + c8.5-3.5,22.8-8.7,40-12.8c23.3-5.6,56.3-10,86.4-1.6c-5.2,6.4-10.6,12.5-16.4,18.4c-8.4-2.3-29.8-6.7-58.7-3.6 + c-31.4,3.4-77.6,16.8-124.8,60.4v-10.9c0-3.4-2.8-6.2-6.2-6.2s-6.2,2.8-6.2,6.2v10.3c-46.9-43.1-92.9-56.4-124.1-59.8 + c-29.6-3.2-51.3,1.6-59.2,3.8C165.2,609,159.9,603.1,154.9,597z M181.2,624.8c10-2,26.9-4,48-1.6c55.5,6.2,98.1,37.9,124.8,64.5 + v18.1c-0.9,0.5-1.9,1.1-3,1.9c-6.5-8.3-15-18-25.7-27.4c-25.2-22.3-67.6-47.5-126.2-40.5l0,0.1 + C192.9,635.1,186.9,630.1,181.2,624.8z M336.5,722.8c-11.7-15-24.5-26.3-37.3-34.8l0.1-0.3c-0.3-0.1-0.6-0.2-0.9-0.2 + c-21.3-13.9-42.7-20-59.9-22.5c-8.1-4.2-16.1-8.9-23.8-14c37.7-1.1,71.9,11.7,102.1,38.2c10.4,9.1,18.6,18.5,24.8,26.6 + C339.7,717.9,338,720.2,336.5,722.8z M366.4,760.3c-1.5,1.8-3.1,3.6-4.9,5.4c-0.4,0.4-0.9,0.9-1.4,1.3c0,0-0.1,0.1-0.1,0.1 + c0,0-0.1,0.1-0.1,0.1l0-0.2c-0.2-0.2-0.5-0.4-0.7-0.7c-2-1.9-3.7-3.8-5.3-5.7c-7.3-8.7-10.5-16.8-9.4-24.2c0.1-0.6,0.2-1.2,0.4-1.8 + c0.8-3.2,2.3-6,4.1-8.4c1.5-2,3.2-3.7,4.9-5.2c1.5-1.3,3-2.4,4.3-3.3c0.6-0.4,1.1-0.7,1.6-1c0,0,0.1,0,0.1-0.1c0,0,0.1,0,0.1,0 + c0.7,0.4,1.4,0.9,2.3,1.5c1.2,0.8,2.6,1.9,4,3.1c1.8,1.6,3.6,3.5,5.1,5.6c1.8,2.6,3.3,5.5,3.9,8.8c0,0.2,0.1,0.5,0.2,0.7 + C376.6,743.6,373.5,751.6,366.4,760.3z M390.8,715.2c-0.2,0.2-0.4,0.4-0.5,0.6c-2.3,2.6-4.4,5.2-6.3,7.7c-1.5-2.6-3.2-5-5-7.1 + c6.2-8.1,14.6-17.8,25.2-27.1c30-26.4,64.1-39.2,101.5-38.3c-7.8,5.1-15.8,9.9-24,14.1C438.9,671.6,409.6,693.8,390.8,715.2z + M565.3,703.4c5.7,0,10.3,4.6,10.3,10.3c0,5.7-4.6,10.3-10.3,10.3s-10.3-4.6-10.3-10.3C555,708.1,559.6,703.4,565.3,703.4z + M596.5,735.5c13.6,4,23.2,18.9,25.8,23.4c3.2,5.6,7.5,13,13.8,18.8h-69.1c-2.6-9.4-6.3-18.3-11-26.5 + C563.6,737.6,580.6,730.8,596.5,735.5z"/> + <path class="st2" d="M69.6,301.1c0,10.1,8.2,18.4,18.4,18.4s18.4-8.2,18.4-18.4c0-10.1-8.2-18.4-18.4-18.4S69.6,291,69.6,301.1z + M94,301.1c0,3.3-2.7,6-6,6c-3.3,0-6-2.7-6-6c0-3.3,2.7-6,6-6C91.3,295.1,94,297.8,94,301.1z"/> + <path class="st2" d="M648.7,301.1c0-10.1-8.2-18.4-18.4-18.4c-10.1,0-18.4,8.2-18.4,18.4c0,10.1,8.2,18.4,18.4,18.4 + C640.5,319.5,648.7,311.3,648.7,301.1z M624.3,301.1c0-3.3,2.7-6,6-6c3.3,0,6,2.7,6,6c0,3.3-2.7,6-6,6 + C627,307.2,624.3,304.5,624.3,301.1z"/> + <path class="st2" d="M666.4,346.9c-2.3-0.9-5-0.4-6.8,1.4c-8.1,8.3-21.4,8-21.6,8c-0.1,0-0.2,0-0.2,0c-3.3,0-6,2.6-6.2,5.9 + c-0.1,3.4,2.5,6.3,5.9,6.4c0.6,0,10.4,0.3,20.4-4.3v100.9c0,3.4,2.8,6.2,6.2,6.2s6.2-2.8,6.2-6.2V352.6 + C670.2,350.1,668.7,347.8,666.4,346.9z"/> + <path class="st2" d="M55.7,471.4c3.4,0,6.2-2.8,6.2-6.2V364.3c10,4.6,19.8,4.3,20.4,4.3c3.4-0.1,6.1-3,5.9-6.4 + c-0.1-3.4-3-6.1-6.4-5.9c-0.1,0-13.5,0.3-21.6-8c-1.8-1.8-4.4-2.3-6.8-1.4c-2.3,0.9-3.8,3.2-3.8,5.7v112.7 + C49.5,468.7,52.3,471.4,55.7,471.4z"/> + <path class="st2" d="M671.3,574.3c0-3.4-2.8-6.2-6.2-6.2h-40c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h40 + C668.5,580.5,671.3,577.8,671.3,574.3z"/> + <path class="st2" d="M629.8,598c3.4,0,6.2-2.8,6.2-6.2s-2.8-6.2-6.2-6.2h-15.4c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2H629.8z"/> + <path class="st2" d="M644.8,609.4c0-3.4-2.8-6.2-6.2-6.2h-38.2c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h38.2 + C642,615.6,644.8,612.9,644.8,609.4z"/> + <path class="st2" d="M620.6,627.1c0-3.4-2.8-6.2-6.2-6.2h-29.9c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h29.9 + C617.8,633.3,620.6,630.5,620.6,627.1z"/> + <path class="st2" d="M565.1,638.6c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h17c3.4,0,6.2-2.8,6.2-6.2s-2.8-6.2-6.2-6.2H565.1z"/> + <path class="st2" d="M665.1,532.8h-22.3c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h22.3c3.4,0,6.2-2.8,6.2-6.2 + S668.5,532.8,665.1,532.8z"/> + <path class="st2" d="M665.1,550.5H634c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h31.1c3.4,0,6.2-2.8,6.2-6.2S668.5,550.5,665.1,550.5 + z"/> + <path class="st2" d="M665.1,497.5h-9.9c-3.4,0-6.2,2.8-6.2,6.2c0,3.4,2.8,6.2,6.2,6.2h9.9c3.4,0,6.2-2.8,6.2-6.2 + C671.3,500.3,668.5,497.5,665.1,497.5z"/> + <path class="st2" d="M665.1,515.2h-15.2c-3.4,0-6.2,2.8-6.2,6.2s2.8,6.2,6.2,6.2h15.2c3.4,0,6.2-2.8,6.2-6.2 + S668.5,515.2,665.1,515.2z"/> + <circle class="st2" cx="55.5" cy="485.6" r="6.2"/> + <circle class="st2" cx="80.5" cy="627.3" r="6.2"/> + <circle class="st2" cx="120.3" cy="645" r="6.2"/> + <path class="st2" d="M54.9,580.7h40c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2h-40c-3.4,0-6.2,2.8-6.2,6.2 + C48.7,578,51.5,580.7,54.9,580.7z"/> + <path class="st2" d="M105.6,585.9H90.2c-3.4,0-6.2,2.8-6.2,6.2c0,3.4,2.8,6.2,6.2,6.2h15.4c3.4,0,6.2-2.8,6.2-6.2 + C111.8,588.7,109,585.9,105.6,585.9z"/> + <path class="st2" d="M119.6,603.5H81.4c-3.4,0-6.2,2.8-6.2,6.2c0,3.4,2.8,6.2,6.2,6.2h38.2c3.4,0,6.2-2.8,6.2-6.2 + C125.8,606.2,123,603.5,119.6,603.5z"/> + <path class="st2" d="M135.5,621.1h-29.9c-3.4,0-6.2,2.8-6.2,6.2c0,3.4,2.8,6.2,6.2,6.2h29.9c3.4,0,6.2-2.8,6.2-6.2 + C141.7,623.9,138.9,621.1,135.5,621.1z"/> + <path class="st2" d="M131.7,645c0,3.4,2.8,6.2,6.2,6.2h17c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2h-17 + C134.5,638.8,131.7,641.6,131.7,645z"/> + <path class="st2" d="M54.9,545.4h22.3c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2H54.9c-3.4,0-6.2,2.8-6.2,6.2 + C48.7,542.6,51.5,545.4,54.9,545.4z"/> + <path class="st2" d="M54.9,563.1H86c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2H54.9c-3.4,0-6.2,2.8-6.2,6.2 + C48.7,560.3,51.5,563.1,54.9,563.1z"/> + <path class="st2" d="M54.9,510.1h9.9c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2h-9.9c-3.4,0-6.2,2.8-6.2,6.2 + C48.7,507.3,51.5,510.1,54.9,510.1z"/> + <path class="st2" d="M54.9,527.7h15.2c3.4,0,6.2-2.8,6.2-6.2c0-3.4-2.8-6.2-6.2-6.2H54.9c-3.4,0-6.2,2.8-6.2,6.2 + C48.7,525,51.5,527.7,54.9,527.7z"/> + <path class="st2" d="M360,767.1C360,767.1,360,767.1,360,767.1c0.1-0.1,0-0.1,0-0.2L360,767.1z"/> + <path class="st2" d="M641.2,202.7C641.3,202.7,641.3,202.7,641.2,202.7c2.8,0,5.3-1.9,6-4.7c2.2-8.8,1.3-16.3-2.9-22.5 + c-6.7-10-19-11.9-20.4-12.1c-3.2-0.4-6.2,1.6-6.9,4.8c-2.3,10.5-1,18.9,4,25.1C628.4,202.3,640,202.6,641.2,202.7z M634.2,182.5 + c1.2,1.9,1.9,4.1,1.9,6.7c-1.8-0.7-3.8-1.8-5.2-3.6c-1.6-2-2.4-4.6-2.4-7.9C630.5,178.7,632.7,180.3,634.2,182.5z"/> + <path class="st2" d="M601,113.3c-6.7-10-19-11.9-20.4-12.1c-3.2-0.4-6.2,1.6-6.9,4.8c-2.3,10.5-1,18.9,4,25.1 + c7.2,9,18.8,9.3,20.1,9.3c0,0,0,0,0,0c2.8,0,5.3-1.9,6-4.7C606.1,127.1,605.2,119.5,601,113.3z M587.5,123.4 + c-1.6-2-2.4-4.6-2.4-7.9c2.1,1,4.3,2.6,5.7,4.8c1.2,1.9,1.9,4.1,1.9,6.7C590.9,126.3,588.9,125.2,587.5,123.4z"/> + <path class="st2" d="M97.3,163.4c-1.4,0.2-13.6,2.1-20.4,12.1c-4.1,6.2-5.1,13.7-2.9,22.5c0.7,2.7,3.2,4.7,6,4.7c0,0,0,0,0,0 + c1.3,0,12.9-0.3,20.1-9.3c5-6.2,6.3-14.7,4-25.1C103.5,165.1,100.5,163,97.3,163.4z M90.6,185.6c-1.4,1.8-3.4,2.9-5.3,3.6 + c0-2.6,0.7-4.9,1.9-6.7c1.5-2.2,3.7-3.8,5.7-4.8C92.9,181,92.1,183.6,90.6,185.6z"/> + <path class="st2" d="M139.7,101.2c-1.4,0.2-13.6,2.1-20.4,12.1c-4.1,6.2-5.1,13.7-2.9,22.5c0.7,2.7,3.2,4.7,6,4.7c0,0,0,0,0,0 + c1.3,0,12.9-0.3,20.1-9.3c5-6.2,6.3-14.7,4-25.1C145.8,102.9,142.9,100.8,139.7,101.2z M132.9,123.4c-1.4,1.8-3.4,2.9-5.3,3.6 + c0-2.6,0.7-4.9,1.9-6.7c1.5-2.2,3.7-3.8,5.7-4.8C135.2,118.8,134.5,121.4,132.9,123.4z"/> + <path class="st2" d="M555.1,392c0.4,3.2,3,5.5,6.1,5.5c0.2,0,0.5,0,0.7,0c3.4-0.4,5.8-3.5,5.4-6.9c-6.2-54-43.1-92.2-44.7-93.8 + c-2.4-2.4-6.3-2.5-8.7-0.1c-2.4,2.4-2.5,6.3-0.1,8.7C514.2,305.8,549.4,342.3,555.1,392z"/> + <path class="st2" d="M159.1,397.4c0.2,0,0.5,0,0.7,0c3.1,0,5.8-2.3,6.1-5.5c5.7-49.7,40.9-86.2,41.2-86.5c2.4-2.4,2.3-6.4-0.1-8.7 + c-2.4-2.4-6.4-2.4-8.7,0.1c-1.6,1.6-38.4,39.8-44.7,93.8C153.3,394,155.7,397,159.1,397.4z"/> +</g> +</svg> diff --git a/gb-src/docs/grammar.md b/gb-src/docs/grammar.md new file mode 100644 index 0000000000000..1bbe77dc5ecf0 --- /dev/null +++ b/gb-src/docs/grammar.md @@ -0,0 +1,6 @@ + +# Block Grammar + +<dl><dt></dt><dd><pre><header>Block_List</header> = $(!Block .)* (Block $(!Block .)*)* $(.*)</pre></dd><dt></dt><dd><pre><header>Block</header> = Block_Void + / Block_Balanced</pre></dd><dt></dt><dd><pre><header>Block_Void</header> = "<!--" __ "wp:" Block_Name __ (Block_Attributes __)? "/-->"</pre></dd><dt></dt><dd><pre><header>Block_Balanced</header> = Block_Start (Block / $(!Block !Block_End .)+)* Block_End</pre></dd><dt></dt><dd><pre><header>Block_Start</header> = "<!--" __ "wp:" Block_Name __ (Block_Attributes __)? "-->"</pre></dd><dt></dt><dd><pre><header>Block_End</header> = "<!--" __ "/wp:" Block_Name __ "-->"</pre></dd><dt></dt><dd><pre><header>Block_Name</header> = Namespaced_Block_Name + / Core_Block_Name</pre></dd><dt></dt><dd><pre><header>Namespaced_Block_Name</header> = $(Block_Name_Part "/" Block_Name_Part)</pre></dd><dt></dt><dd><pre><header>Core_Block_Name</header> = $(Block_Name_Part)</pre></dd><dt></dt><dd><pre><header>Block_Name_Part</header> = $([a-z] [a-z0-9_-]*)</pre></dd><dt>JSON-encoded attributes embedded in a block's opening comment</dt><dd><pre><header>Block_Attributes</header> = $("{" (!("}" __ "" "/"? "-->") .)* "}")</pre></dd><dt></dt><dd><pre><header>__</header> = [ \t\r\n]+</pre></dd></dl> diff --git a/gb-src/docs/manifest-devhub.json b/gb-src/docs/manifest-devhub.json new file mode 100644 index 0000000000000..68958986ae8ea --- /dev/null +++ b/gb-src/docs/manifest-devhub.json @@ -0,0 +1,1418 @@ +[ + { + "title": "Block Editor Handbook", + "slug": "handbook", + "markdown_source": "../docs/readme.md", + "parent": null + }, + { + "title": "Key Concepts", + "slug": "key-concepts", + "markdown_source": "../docs/designers-developers/key-concepts.md", + "parent": null + }, + { + "title": "Developer Documentation", + "slug": "developers", + "markdown_source": "../docs/designers-developers/developers/README.md", + "parent": null + }, + { + "title": "Block API Reference", + "slug": "block-api", + "markdown_source": "../docs/designers-developers/developers/block-api/README.md", + "parent": "developers" + }, + { + "title": "Block Registration", + "slug": "block-registration", + "markdown_source": "../docs/designers-developers/developers/block-api/block-registration.md", + "parent": "block-api" + }, + { + "title": "Edit and Save", + "slug": "block-edit-save", + "markdown_source": "../docs/designers-developers/developers/block-api/block-edit-save.md", + "parent": "block-api" + }, + { + "title": "Attributes", + "slug": "block-attributes", + "markdown_source": "../docs/designers-developers/developers/block-api/block-attributes.md", + "parent": "block-api" + }, + { + "title": "Deprecated Blocks", + "slug": "block-deprecation", + "markdown_source": "../docs/designers-developers/developers/block-api/block-deprecation.md", + "parent": "block-api" + }, + { + "title": "Templates", + "slug": "block-templates", + "markdown_source": "../docs/designers-developers/developers/block-api/block-templates.md", + "parent": "block-api" + }, + { + "title": "Annotations", + "slug": "block-annotations", + "markdown_source": "../docs/designers-developers/developers/block-api/block-annotations.md", + "parent": "block-api" + }, + { + "title": "Filter Reference", + "slug": "filters", + "markdown_source": "../docs/designers-developers/developers/filters/README.md", + "parent": "developers" + }, + { + "title": "Block Filters", + "slug": "block-filters", + "markdown_source": "../docs/designers-developers/developers/filters/block-filters.md", + "parent": "filters" + }, + { + "title": "Editor Filters (Experimental)", + "slug": "editor-filters", + "markdown_source": "../docs/designers-developers/developers/filters/editor-filters.md", + "parent": "filters" + }, + { + "title": "Parser Filters", + "slug": "parser-filters", + "markdown_source": "../docs/designers-developers/developers/filters/parser-filters.md", + "parent": "filters" + }, + { + "title": "Autocomplete", + "slug": "autocomplete-filters", + "markdown_source": "../docs/designers-developers/developers/filters/autocomplete-filters.md", + "parent": "filters" + }, + { + "title": "SlotFills Reference", + "slug": "slotfills", + "markdown_source": "../docs/designers-developers/developers/slotfills/README.md", + "parent": "developers" + }, + { + "title": "PluginBlockSettingsMenuItem", + "slug": "plugin-block-settings-menu-item", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md", + "parent": "slotfills" + }, + { + "title": "PluginDocumentSettingPanel", + "slug": "plugin-document-setting-panel", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md", + "parent": "slotfills" + }, + { + "title": "PluginMoreMenuItem", + "slug": "plugin-more-menu-item", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-more-menu-item.md", + "parent": "slotfills" + }, + { + "title": "PluginPostPublishPanel", + "slug": "plugin-post-publish-panel", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md", + "parent": "slotfills" + }, + { + "title": "PluginPostStatusInfo", + "slug": "plugin-post-status-info", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-post-status-info.md", + "parent": "slotfills" + }, + { + "title": "PluginPrePublishPanel", + "slug": "plugin-pre-publish-panel", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md", + "parent": "slotfills" + }, + { + "title": "PluginSidebar", + "slug": "plugin-sidebar", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-sidebar.md", + "parent": "slotfills" + }, + { + "title": "PluginSidebarMoreMenuItem", + "slug": "plugin-sidebar-more-menu-item", + "markdown_source": "../docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md", + "parent": "slotfills" + }, + { + "title": "RichText Reference", + "slug": "richtext", + "markdown_source": "../docs/designers-developers/developers/richtext.md", + "parent": "developers" + }, + { + "title": "Internationalization", + "slug": "internationalization", + "markdown_source": "../docs/designers-developers/developers/internationalization.md", + "parent": "developers" + }, + { + "title": "Accessibility", + "slug": "accessibility", + "markdown_source": "../docs/designers-developers/developers/accessibility.md", + "parent": "developers" + }, + { + "title": "Feature Flags", + "slug": "feature-flags", + "markdown_source": "../docs/designers-developers/developers/feature-flags.md", + "parent": "developers" + }, + { + "title": "Theming for the Block Editor", + "slug": "themes", + "markdown_source": "../docs/designers-developers/developers/themes/README.md", + "parent": "developers" + }, + { + "title": "Theme Support", + "slug": "theme-support", + "markdown_source": "../docs/designers-developers/developers/themes/theme-support.md", + "parent": "themes" + }, + { + "title": "Backward Compatibility", + "slug": "backward-compatibility", + "markdown_source": "../docs/designers-developers/developers/backward-compatibility/README.md", + "parent": "developers" + }, + { + "title": "Deprecations", + "slug": "deprecations", + "markdown_source": "../docs/designers-developers/developers/backward-compatibility/deprecations.md", + "parent": "backward-compatibility" + }, + { + "title": "Meta Boxes", + "slug": "meta-box", + "markdown_source": "../docs/designers-developers/developers/backward-compatibility/meta-box.md", + "parent": "backward-compatibility" + }, + { + "title": "Designer Documentation", + "slug": "designers", + "markdown_source": "../docs/designers-developers/designers/README.md", + "parent": null + }, + { + "title": "Block Design", + "slug": "block-design", + "markdown_source": "../docs/designers-developers/designers/block-design.md", + "parent": "designers" + }, + { + "title": "Patterns", + "slug": "design-patterns", + "markdown_source": "../docs/designers-developers/designers/design-patterns.md", + "parent": "designers" + }, + { + "title": "Resources", + "slug": "design-resources", + "markdown_source": "../docs/designers-developers/designers/design-resources.md", + "parent": "designers" + }, + { + "title": "Animation", + "slug": "animation", + "markdown_source": "../docs/designers-developers/designers/animation.md", + "parent": "designers" + }, + { + "title": "Contributor Documentation", + "slug": "contributors", + "markdown_source": "../docs/contributors/readme.md", + "parent": null + }, + { + "title": "Principles", + "slug": "principles", + "markdown_source": "../docs/contributors/principles.md", + "parent": "contributors" + }, + { + "title": "Design Principles & Vision", + "slug": "design", + "markdown_source": "../docs/contributors/design.md", + "parent": "contributors" + }, + { + "title": "Blocks are the Interface", + "slug": "the-block", + "markdown_source": "../docs/contributors/principles/the-block.md", + "parent": "design" + }, + { + "title": "Reference", + "slug": "reference", + "markdown_source": "../docs/contributors/reference.md", + "parent": "design" + }, + { + "title": "Developer Contributions", + "slug": "develop", + "markdown_source": "../docs/contributors/develop.md", + "parent": "contributors" + }, + { + "title": "Getting Started", + "slug": "getting-started", + "markdown_source": "../docs/contributors/getting-started.md", + "parent": "develop" + }, + { + "title": "Git Workflow", + "slug": "git-workflow", + "markdown_source": "../docs/contributors/git-workflow.md", + "parent": "develop" + }, + { + "title": "Coding Guidelines", + "slug": "coding-guidelines", + "markdown_source": "../docs/contributors/coding-guidelines.md", + "parent": "develop" + }, + { + "title": "Testing Overview", + "slug": "testing-overview", + "markdown_source": "../docs/contributors/testing-overview.md", + "parent": "develop" + }, + { + "title": "Block Grammar", + "slug": "grammar", + "markdown_source": "../docs/contributors/grammar.md", + "parent": "develop" + }, + { + "title": "Scripts", + "slug": "scripts", + "markdown_source": "../docs/contributors/scripts.md", + "parent": "develop" + }, + { + "title": "Managing Packages", + "slug": "managing-packages", + "markdown_source": "../docs/contributors/managing-packages.md", + "parent": "develop" + }, + { + "title": "Gutenberg Release Process", + "slug": "release", + "markdown_source": "../docs/contributors/release.md", + "parent": "develop" + }, + { + "title": "Localizing Gutenberg Plugin", + "slug": "localizing", + "markdown_source": "../docs/contributors/localizing.md", + "parent": "develop" + }, + { + "title": "Documentation Contributions", + "slug": "document", + "markdown_source": "../docs/contributors/document.md", + "parent": "contributors" + }, + { + "title": "Copy Guidelines", + "slug": "copy-guide", + "markdown_source": "../docs/contributors/copy-guide.md", + "parent": "document" + }, + { + "title": "History", + "slug": "history", + "markdown_source": "../docs/contributors/history.md", + "parent": "contributors" + }, + { + "title": "Glossary", + "slug": "glossary", + "markdown_source": "../docs/designers-developers/glossary.md", + "parent": "contributors" + }, + { + "title": "Frequently Asked Questions", + "slug": "faq", + "markdown_source": "../docs/designers-developers/faq.md", + "parent": "contributors" + }, + { + "title": "Repository Management", + "slug": "repository-management", + "markdown_source": "../docs/contributors/repository-management.md", + "parent": "contributors" + }, + { + "title": "Outreach", + "slug": "outreach", + "markdown_source": "../docs/contributors/outreach.md", + "parent": "contributors" + }, + { + "title": "Tutorials", + "slug": "tutorials", + "markdown_source": "../docs/designers-developers/developers/tutorials/readme.md", + "parent": null + }, + { + "title": "Getting Started with JavaScript", + "slug": "javascript", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/readme.md", + "parent": "tutorials" + }, + { + "title": "Plugins Background", + "slug": "plugins-background", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/plugins-background.md", + "parent": "javascript" + }, + { + "title": "Loading JavaScript", + "slug": "loading-javascript", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/loading-javascript.md", + "parent": "javascript" + }, + { + "title": "Extending the Block Editor", + "slug": "extending-the-block-editor", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md", + "parent": "javascript" + }, + { + "title": "Troubleshooting", + "slug": "troubleshooting", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/troubleshooting.md", + "parent": "javascript" + }, + { + "title": "JavaScript Versions and Build Step", + "slug": "versions-and-building", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/versions-and-building.md", + "parent": "javascript" + }, + { + "title": "Scope Your Code", + "slug": "scope-your-code", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/scope-your-code.md", + "parent": "javascript" + }, + { + "title": "JavaScript Build Setup", + "slug": "js-build-setup", + "markdown_source": "../docs/designers-developers/developers/tutorials/javascript/js-build-setup.md", + "parent": "javascript" + }, + { + "title": "Blocks", + "slug": "block-tutorial", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/readme.md", + "parent": "tutorials" + }, + { + "title": "Writing Your First Block Type", + "slug": "writing-your-first-block-type", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md", + "parent": "block-tutorial" + }, + { + "title": "Applying Styles From a Stylesheet", + "slug": "applying-styles-with-stylesheets", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md", + "parent": "block-tutorial" + }, + { + "title": "Introducing Attributes and Editable Fields", + "slug": "introducing-attributes-and-editable-fields", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md", + "parent": "block-tutorial" + }, + { + "title": "Block Controls: Block Toolbar and Settings Sidebar", + "slug": "block-controls-toolbar-and-sidebar", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md", + "parent": "block-tutorial" + }, + { + "title": "Creating dynamic blocks", + "slug": "creating-dynamic-blocks", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md", + "parent": "block-tutorial" + }, + { + "title": "Generate Blocks with WP-CLI", + "slug": "generate-blocks-with-wp-cli", + "markdown_source": "../docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md", + "parent": "block-tutorial" + }, + { + "title": "Meta Boxes", + "slug": "metabox", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/readme.md", + "parent": "tutorials" + }, + { + "title": "Store Post Meta with a Block", + "slug": "meta-block-1-intro", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md", + "parent": "metabox" + }, + { + "title": "Register Meta Field", + "slug": "meta-block-2-register-meta", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md", + "parent": "metabox" + }, + { + "title": "Create Meta Block", + "slug": "meta-block-3-add", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md", + "parent": "metabox" + }, + { + "title": "Use Post Meta Data", + "slug": "meta-block-4-use-data", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md", + "parent": "metabox" + }, + { + "title": "Finishing Touches", + "slug": "meta-block-5-finishing", + "markdown_source": "../docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md", + "parent": "metabox" + }, + { + "title": "Displaying Notices from Your Plugin or Theme", + "slug": "notices", + "markdown_source": "../docs/designers-developers/developers/tutorials/notices/README.md", + "parent": "tutorials" + }, + { + "title": "Creating a Sidebar for Your Plugin", + "slug": "plugin-sidebar-0", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md", + "parent": "tutorials" + }, + { + "title": "Get a Sidebar up and Running", + "slug": "plugin-sidebar-1-up-and-running", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Tweak the sidebar style and add controls", + "slug": "plugin-sidebar-2-styles-and-controls", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Register the Meta Field", + "slug": "plugin-sidebar-3-register-meta", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Initialize the Input Control", + "slug": "plugin-sidebar-4-initialize-input", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Update the Meta Field When the Input's Content Changes", + "slug": "plugin-sidebar-5-update-meta", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Finishing Touches", + "slug": "plugin-sidebar-6-finishing-touches", + "markdown_source": "../docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Introduction to the Format API", + "slug": "format-api", + "markdown_source": "../docs/designers-developers/developers/tutorials/format-api/README.md", + "parent": "tutorials" + }, + { + "title": "Register a New Format", + "slug": "1-register-format", + "markdown_source": "../docs/designers-developers/developers/tutorials/format-api/1-register-format.md", + "parent": "format-api" + }, + { + "title": "Add a Button to the Toolbar", + "slug": "2-toolbar-button", + "markdown_source": "../docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md", + "parent": "format-api" + }, + { + "title": "Apply the Format When the Button Is Clicked", + "slug": "3-apply-format", + "markdown_source": "../docs/designers-developers/developers/tutorials/format-api/3-apply-format.md", + "parent": "format-api" + }, + { + "title": "Component Reference", + "slug": "components", + "markdown_source": "../packages/components/README.md", + "parent": null + }, + { + "title": "Animate", + "slug": "animate", + "markdown_source": "../packages/components/src/animate/README.md", + "parent": "components" + }, + { + "title": "Autocomplete", + "slug": "autocomplete", + "markdown_source": "../packages/components/src/autocomplete/README.md", + "parent": "components" + }, + { + "title": "BaseControl", + "slug": "base-control", + "markdown_source": "../packages/components/src/base-control/README.md", + "parent": "components" + }, + { + "title": "ButtonGroup", + "slug": "button-group", + "markdown_source": "../packages/components/src/button-group/README.md", + "parent": "components" + }, + { + "title": "Button", + "slug": "button", + "markdown_source": "../packages/components/src/button/README.md", + "parent": "components" + }, + { + "title": "CheckboxControl", + "slug": "checkbox-control", + "markdown_source": "../packages/components/src/checkbox-control/README.md", + "parent": "components" + }, + { + "title": "ClipboardButton", + "slug": "clipboard-button", + "markdown_source": "../packages/components/src/clipboard-button/README.md", + "parent": "components" + }, + { + "title": "ColorIndicator", + "slug": "color-indicator", + "markdown_source": "../packages/components/src/color-indicator/README.md", + "parent": "components" + }, + { + "title": "ColorPalette", + "slug": "color-palette", + "markdown_source": "../packages/components/src/color-palette/README.md", + "parent": "components" + }, + { + "title": "ColorPicker", + "slug": "color-picker", + "markdown_source": "../packages/components/src/color-picker/README.md", + "parent": "components" + }, + { + "title": "Dashicon", + "slug": "dashicon", + "markdown_source": "../packages/components/src/dashicon/README.md", + "parent": "components" + }, + { + "title": "DateTime", + "slug": "date-time", + "markdown_source": "../packages/components/src/date-time/README.md", + "parent": "components" + }, + { + "title": "Disabled", + "slug": "disabled", + "markdown_source": "../packages/components/src/disabled/README.md", + "parent": "components" + }, + { + "title": "Draggable", + "slug": "draggable", + "markdown_source": "../packages/components/src/draggable/README.md", + "parent": "components" + }, + { + "title": "DropZone", + "slug": "drop-zone", + "markdown_source": "../packages/components/src/drop-zone/README.md", + "parent": "components" + }, + { + "title": "DropdownMenu", + "slug": "dropdown-menu", + "markdown_source": "../packages/components/src/dropdown-menu/README.md", + "parent": "components" + }, + { + "title": "Dropdown", + "slug": "dropdown", + "markdown_source": "../packages/components/src/dropdown/README.md", + "parent": "components" + }, + { + "title": "ExternalLink", + "slug": "external-link", + "markdown_source": "../packages/components/src/external-link/README.md", + "parent": "components" + }, + { + "title": "FocalPointPicker", + "slug": "focal-point-picker", + "markdown_source": "../packages/components/src/focal-point-picker/README.md", + "parent": "components" + }, + { + "title": "FocusableIframe", + "slug": "focusable-iframe", + "markdown_source": "../packages/components/src/focusable-iframe/README.md", + "parent": "components" + }, + { + "title": "FontSizePicker", + "slug": "font-size-picker", + "markdown_source": "../packages/components/src/font-size-picker/README.md", + "parent": "components" + }, + { + "title": "FormFileUpload", + "slug": "form-file-upload", + "markdown_source": "../packages/components/src/form-file-upload/README.md", + "parent": "components" + }, + { + "title": "FormToggle", + "slug": "form-toggle", + "markdown_source": "../packages/components/src/form-toggle/README.md", + "parent": "components" + }, + { + "title": "FormTokenField", + "slug": "form-token-field", + "markdown_source": "../packages/components/src/form-token-field/README.md", + "parent": "components" + }, + { + "title": "NavigateRegions", + "slug": "navigate-regions", + "markdown_source": "../packages/components/src/higher-order/navigate-regions/README.md", + "parent": "components" + }, + { + "title": "HigherOrder", + "slug": "higher-order", + "markdown_source": "../packages/components/src/higher-order/README.md", + "parent": "components" + }, + { + "title": "WithConstrainedTabbing", + "slug": "with-constrained-tabbing", + "markdown_source": "../packages/components/src/higher-order/with-constrained-tabbing/README.md", + "parent": "components" + }, + { + "title": "WithFallbackStyles", + "slug": "with-fallback-styles", + "markdown_source": "../packages/components/src/higher-order/with-fallback-styles/README.md", + "parent": "components" + }, + { + "title": "WithFilters", + "slug": "with-filters", + "markdown_source": "../packages/components/src/higher-order/with-filters/README.md", + "parent": "components" + }, + { + "title": "WithFocusOutside", + "slug": "with-focus-outside", + "markdown_source": "../packages/components/src/higher-order/with-focus-outside/README.md", + "parent": "components" + }, + { + "title": "WithFocusReturn", + "slug": "with-focus-return", + "markdown_source": "../packages/components/src/higher-order/with-focus-return/README.md", + "parent": "components" + }, + { + "title": "WithNotices", + "slug": "with-notices", + "markdown_source": "../packages/components/src/higher-order/with-notices/README.md", + "parent": "components" + }, + { + "title": "WithSpokenMessages", + "slug": "with-spoken-messages", + "markdown_source": "../packages/components/src/higher-order/with-spoken-messages/README.md", + "parent": "components" + }, + { + "title": "IconButton", + "slug": "icon-button", + "markdown_source": "../packages/components/src/icon-button/README.md", + "parent": "components" + }, + { + "title": "Icon", + "slug": "icon", + "markdown_source": "../packages/components/src/icon/README.md", + "parent": "components" + }, + { + "title": "IsolatedEventContainer", + "slug": "isolated-event-container", + "markdown_source": "../packages/components/src/isolated-event-container/README.md", + "parent": "components" + }, + { + "title": "KeyboardShortcuts", + "slug": "keyboard-shortcuts", + "markdown_source": "../packages/components/src/keyboard-shortcuts/README.md", + "parent": "components" + }, + { + "title": "MenuGroup", + "slug": "menu-group", + "markdown_source": "../packages/components/src/menu-group/README.md", + "parent": "components" + }, + { + "title": "MenuItem", + "slug": "menu-item", + "markdown_source": "../packages/components/src/menu-item/README.md", + "parent": "components" + }, + { + "title": "MenuItemsChoice", + "slug": "menu-items-choice", + "markdown_source": "../packages/components/src/menu-items-choice/README.md", + "parent": "components" + }, + { + "title": "Modal", + "slug": "modal", + "markdown_source": "../packages/components/src/modal/README.md", + "parent": "components" + }, + { + "title": "NavigableContainer", + "slug": "navigable-container", + "markdown_source": "../packages/components/src/navigable-container/README.md", + "parent": "components" + }, + { + "title": "Notice", + "slug": "notice", + "markdown_source": "../packages/components/src/notice/README.md", + "parent": "components" + }, + { + "title": "Panel", + "slug": "panel", + "markdown_source": "../packages/components/src/panel/README.md", + "parent": "components" + }, + { + "title": "Placeholder", + "slug": "placeholder", + "markdown_source": "../packages/components/src/placeholder/README.md", + "parent": "components" + }, + { + "title": "Popover", + "slug": "popover", + "markdown_source": "../packages/components/src/popover/README.md", + "parent": "components" + }, + { + "title": "BlockQuotation", + "slug": "block-quotation", + "markdown_source": "../packages/components/src/primitives/block-quotation/README.md", + "parent": "components" + }, + { + "title": "HorizontalRule", + "slug": "horizontal-rule", + "markdown_source": "../packages/components/src/primitives/horizontal-rule/README.md", + "parent": "components" + }, + { + "title": "Svg", + "slug": "svg", + "markdown_source": "../packages/components/src/primitives/svg/README.md", + "parent": "components" + }, + { + "title": "QueryControls", + "slug": "query-controls", + "markdown_source": "../packages/components/src/query-controls/README.md", + "parent": "components" + }, + { + "title": "RadioControl", + "slug": "radio-control", + "markdown_source": "../packages/components/src/radio-control/README.md", + "parent": "components" + }, + { + "title": "RangeControl", + "slug": "range-control", + "markdown_source": "../packages/components/src/range-control/README.md", + "parent": "components" + }, + { + "title": "ResizableBox", + "slug": "resizable-box", + "markdown_source": "../packages/components/src/resizable-box/README.md", + "parent": "components" + }, + { + "title": "ResponsiveWrapper", + "slug": "responsive-wrapper", + "markdown_source": "../packages/components/src/responsive-wrapper/README.md", + "parent": "components" + }, + { + "title": "Sandbox", + "slug": "sandbox", + "markdown_source": "../packages/components/src/sandbox/README.md", + "parent": "components" + }, + { + "title": "ScrollLock", + "slug": "scroll-lock", + "markdown_source": "../packages/components/src/scroll-lock/README.md", + "parent": "components" + }, + { + "title": "SelectControl", + "slug": "select-control", + "markdown_source": "../packages/components/src/select-control/README.md", + "parent": "components" + }, + { + "title": "SlotFill", + "slug": "slot-fill", + "markdown_source": "../packages/components/src/slot-fill/README.md", + "parent": "components" + }, + { + "title": "Snackbar", + "slug": "snackbar", + "markdown_source": "../packages/components/src/snackbar/README.md", + "parent": "components" + }, + { + "title": "Spinner", + "slug": "spinner", + "markdown_source": "../packages/components/src/spinner/README.md", + "parent": "components" + }, + { + "title": "TabPanel", + "slug": "tab-panel", + "markdown_source": "../packages/components/src/tab-panel/README.md", + "parent": "components" + }, + { + "title": "TextControl", + "slug": "text-control", + "markdown_source": "../packages/components/src/text-control/README.md", + "parent": "components" + }, + { + "title": "TextareaControl", + "slug": "textarea-control", + "markdown_source": "../packages/components/src/textarea-control/README.md", + "parent": "components" + }, + { + "title": "ToggleControl", + "slug": "toggle-control", + "markdown_source": "../packages/components/src/toggle-control/README.md", + "parent": "components" + }, + { + "title": "Toolbar", + "slug": "toolbar", + "markdown_source": "../packages/components/src/toolbar/README.md", + "parent": "components" + }, + { + "title": "Tooltip", + "slug": "tooltip", + "markdown_source": "../packages/components/src/tooltip/README.md", + "parent": "components" + }, + { + "title": "TreeSelect", + "slug": "tree-select", + "markdown_source": "../packages/components/src/tree-select/README.md", + "parent": "components" + }, + { + "title": "Data Module Reference", + "slug": "data", + "markdown_source": "../docs/designers-developers/developers/data/README.md", + "parent": null + }, + { + "title": "WordPress Core Data", + "slug": "data-core", + "markdown_source": "../docs/designers-developers/developers/data/data-core.md", + "parent": "data" + }, + { + "title": "Annotations", + "slug": "data-core-annotations", + "markdown_source": "../docs/designers-developers/developers/data/data-core-annotations.md", + "parent": "data" + }, + { + "title": "Block Types Data", + "slug": "data-core-blocks", + "markdown_source": "../docs/designers-developers/developers/data/data-core-blocks.md", + "parent": "data" + }, + { + "title": "The Block Editor’s Data", + "slug": "data-core-block-editor", + "markdown_source": "../docs/designers-developers/developers/data/data-core-block-editor.md", + "parent": "data" + }, + { + "title": "The Post Editor’s Data", + "slug": "data-core-editor", + "markdown_source": "../docs/designers-developers/developers/data/data-core-editor.md", + "parent": "data" + }, + { + "title": "The Editor’s UI Data", + "slug": "data-core-edit-post", + "markdown_source": "../docs/designers-developers/developers/data/data-core-edit-post.md", + "parent": "data" + }, + { + "title": "Notices Data", + "slug": "data-core-notices", + "markdown_source": "../docs/designers-developers/developers/data/data-core-notices.md", + "parent": "data" + }, + { + "title": "The NUX (New User Experience) Data", + "slug": "data-core-nux", + "markdown_source": "../docs/designers-developers/developers/data/data-core-nux.md", + "parent": "data" + }, + { + "title": "The Viewport Data", + "slug": "data-core-viewport", + "markdown_source": "../docs/designers-developers/developers/data/data-core-viewport.md", + "parent": "data" + }, + { + "title": "Package Reference", + "slug": "packages", + "markdown_source": "../docs/designers-developers/developers/packages.md", + "parent": null + }, + { + "title": "@wordpress/a11y", + "slug": "packages-a11y", + "markdown_source": "../packages/a11y/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/annotations", + "slug": "packages-annotations", + "markdown_source": "../packages/annotations/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/api-fetch", + "slug": "packages-api-fetch", + "markdown_source": "../packages/api-fetch/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/autop", + "slug": "packages-autop", + "markdown_source": "../packages/autop/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-plugin-import-jsx-pragma", + "slug": "packages-babel-plugin-import-jsx-pragma", + "markdown_source": "../packages/babel-plugin-import-jsx-pragma/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-plugin-makepot", + "slug": "packages-babel-plugin-makepot", + "markdown_source": "../packages/babel-plugin-makepot/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-preset-default", + "slug": "packages-babel-preset-default", + "markdown_source": "../packages/babel-preset-default/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/blob", + "slug": "packages-blob", + "markdown_source": "../packages/blob/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-directory", + "slug": "packages-block-directory", + "markdown_source": "../packages/block-directory/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-editor", + "slug": "packages-block-editor", + "markdown_source": "../packages/block-editor/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-library", + "slug": "packages-block-library", + "markdown_source": "../packages/block-library/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-serialization-default-parser", + "slug": "packages-block-serialization-default-parser", + "markdown_source": "../packages/block-serialization-default-parser/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-serialization-spec-parser", + "slug": "packages-block-serialization-spec-parser", + "markdown_source": "../packages/block-serialization-spec-parser/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/blocks", + "slug": "packages-blocks", + "markdown_source": "../packages/blocks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/browserslist-config", + "slug": "packages-browserslist-config", + "markdown_source": "../packages/browserslist-config/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/components", + "slug": "packages-components", + "markdown_source": "../packages/components/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/compose", + "slug": "packages-compose", + "markdown_source": "../packages/compose/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/core-data", + "slug": "packages-core-data", + "markdown_source": "../packages/core-data/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/custom-templated-path-webpack-plugin", + "slug": "packages-custom-templated-path-webpack-plugin", + "markdown_source": "../packages/custom-templated-path-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/data-controls", + "slug": "packages-data-controls", + "markdown_source": "../packages/data-controls/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/data", + "slug": "packages-data", + "markdown_source": "../packages/data/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/date", + "slug": "packages-date", + "markdown_source": "../packages/date/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dependency-extraction-webpack-plugin", + "slug": "packages-dependency-extraction-webpack-plugin", + "markdown_source": "../packages/dependency-extraction-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/deprecated", + "slug": "packages-deprecated", + "markdown_source": "../packages/deprecated/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/docgen", + "slug": "packages-docgen", + "markdown_source": "../packages/docgen/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dom-ready", + "slug": "packages-dom-ready", + "markdown_source": "../packages/dom-ready/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dom", + "slug": "packages-dom", + "markdown_source": "../packages/dom/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/e2e-test-utils", + "slug": "packages-e2e-test-utils", + "markdown_source": "../packages/e2e-test-utils/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/e2e-tests", + "slug": "packages-e2e-tests", + "markdown_source": "../packages/e2e-tests/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/edit-post", + "slug": "packages-edit-post", + "markdown_source": "../packages/edit-post/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/edit-widgets", + "slug": "packages-edit-widgets", + "markdown_source": "../packages/edit-widgets/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/editor", + "slug": "packages-editor", + "markdown_source": "../packages/editor/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/element", + "slug": "packages-element", + "markdown_source": "../packages/element/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/escape-html", + "slug": "packages-escape-html", + "markdown_source": "../packages/escape-html/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/eslint-plugin", + "slug": "packages-eslint-plugin", + "markdown_source": "../packages/eslint-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/format-library", + "slug": "packages-format-library", + "markdown_source": "../packages/format-library/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/hooks", + "slug": "packages-hooks", + "markdown_source": "../packages/hooks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/html-entities", + "slug": "packages-html-entities", + "markdown_source": "../packages/html-entities/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/i18n", + "slug": "packages-i18n", + "markdown_source": "../packages/i18n/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/is-shallow-equal", + "slug": "packages-is-shallow-equal", + "markdown_source": "../packages/is-shallow-equal/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-console", + "slug": "packages-jest-console", + "markdown_source": "../packages/jest-console/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-preset-default", + "slug": "packages-jest-preset-default", + "markdown_source": "../packages/jest-preset-default/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-puppeteer-axe", + "slug": "packages-jest-puppeteer-axe", + "markdown_source": "../packages/jest-puppeteer-axe/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/keycodes", + "slug": "packages-keycodes", + "markdown_source": "../packages/keycodes/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/library-export-default-webpack-plugin", + "slug": "packages-library-export-default-webpack-plugin", + "markdown_source": "../packages/library-export-default-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/list-reusable-blocks", + "slug": "packages-list-reusable-blocks", + "markdown_source": "../packages/list-reusable-blocks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/media-utils", + "slug": "packages-media-utils", + "markdown_source": "../packages/media-utils/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/notices", + "slug": "packages-notices", + "markdown_source": "../packages/notices/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/npm-package-json-lint-config", + "slug": "packages-npm-package-json-lint-config", + "markdown_source": "../packages/npm-package-json-lint-config/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/nux", + "slug": "packages-nux", + "markdown_source": "../packages/nux/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/plugins", + "slug": "packages-plugins", + "markdown_source": "../packages/plugins/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/postcss-themes", + "slug": "packages-postcss-themes", + "markdown_source": "../packages/postcss-themes/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/priority-queue", + "slug": "packages-priority-queue", + "markdown_source": "../packages/priority-queue/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/project-management-automation", + "slug": "packages-project-management-automation", + "markdown_source": "../packages/project-management-automation/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/redux-routine", + "slug": "packages-redux-routine", + "markdown_source": "../packages/redux-routine/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/rich-text", + "slug": "packages-rich-text", + "markdown_source": "../packages/rich-text/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/scripts", + "slug": "packages-scripts", + "markdown_source": "../packages/scripts/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/server-side-render", + "slug": "packages-server-side-render", + "markdown_source": "../packages/server-side-render/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/shortcode", + "slug": "packages-shortcode", + "markdown_source": "../packages/shortcode/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/token-list", + "slug": "packages-token-list", + "markdown_source": "../packages/token-list/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/url", + "slug": "packages-url", + "markdown_source": "../packages/url/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/viewport", + "slug": "packages-viewport", + "markdown_source": "../packages/viewport/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/wordcount", + "slug": "packages-wordcount", + "markdown_source": "../packages/wordcount/README.md", + "parent": "packages" + } +] \ No newline at end of file diff --git a/gb-src/docs/manifest.json b/gb-src/docs/manifest.json new file mode 100644 index 0000000000000..03f85343b014a --- /dev/null +++ b/gb-src/docs/manifest.json @@ -0,0 +1,1346 @@ +[ + { + "title": "Gutenberg Handbook", + "slug": "handbook", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/readme.md", + "parent": null + }, + { + "title": "Designer & Developer Handbook", + "slug": "designers-developers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/readme.md", + "parent": null + }, + { + "title": "Key Concepts", + "slug": "key-concepts", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/key-concepts.md", + "parent": "designers-developers" + }, + { + "title": "Developer Documentation", + "slug": "developers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/README.md", + "parent": "designers-developers" + }, + { + "title": "Block API Reference", + "slug": "block-api", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/README.md", + "parent": "developers" + }, + { + "title": "Block Registration", + "slug": "block-registration", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-registration.md", + "parent": "block-api" + }, + { + "title": "Edit and Save", + "slug": "block-edit-save", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-edit-save.md", + "parent": "block-api" + }, + { + "title": "Attributes", + "slug": "block-attributes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-attributes.md", + "parent": "block-api" + }, + { + "title": "Deprecated Blocks", + "slug": "block-deprecation", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-deprecation.md", + "parent": "block-api" + }, + { + "title": "Templates", + "slug": "block-templates", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-templates.md", + "parent": "block-api" + }, + { + "title": "Annotations", + "slug": "block-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-annotations.md", + "parent": "block-api" + }, + { + "title": "Filter Reference", + "slug": "filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/README.md", + "parent": "developers" + }, + { + "title": "Block Filters", + "slug": "block-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/block-filters.md", + "parent": "filters" + }, + { + "title": "Editor Filters (Experimental)", + "slug": "editor-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/editor-filters.md", + "parent": "filters" + }, + { + "title": "Parser Filters", + "slug": "parser-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/parser-filters.md", + "parent": "filters" + }, + { + "title": "Autocomplete", + "slug": "autocomplete-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/autocomplete-filters.md", + "parent": "filters" + }, + { + "title": "Internationalization", + "slug": "internationalization", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/internationalization.md", + "parent": "developers" + }, + { + "title": "Accessibility", + "slug": "accessibility", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/accessibility.md", + "parent": "developers" + }, + { + "title": "Feature Flags", + "slug": "feature-flags", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/feature-flags.md", + "parent": "developers" + }, + { + "title": "Data Module Reference", + "slug": "data", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/README.md", + "parent": "developers" + }, + { + "title": "WordPress Core Data", + "slug": "data-core", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core.md", + "parent": "data" + }, + { + "title": "Annotations", + "slug": "data-core-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-annotations.md", + "parent": "data" + }, + { + "title": "Block Types Data", + "slug": "data-core-blocks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-blocks.md", + "parent": "data" + }, + { + "title": "The Block Editor’s Data", + "slug": "data-core-block-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-block-editor.md", + "parent": "data" + }, + { + "title": "The Post Editor’s Data", + "slug": "data-core-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-editor.md", + "parent": "data" + }, + { + "title": "The Editor’s UI Data", + "slug": "data-core-edit-post", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-edit-post.md", + "parent": "data" + }, + { + "title": "Notices Data", + "slug": "data-core-notices", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-notices.md", + "parent": "data" + }, + { + "title": "The NUX (New User Experience) Data", + "slug": "data-core-nux", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-nux.md", + "parent": "data" + }, + { + "title": "The Viewport Data", + "slug": "data-core-viewport", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-viewport.md", + "parent": "data" + }, + { + "title": "Packages", + "slug": "packages", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/packages.md", + "parent": "developers" + }, + { + "title": "@wordpress/a11y", + "slug": "packages-a11y", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/a11y/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/annotations", + "slug": "packages-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/annotations/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/api-fetch", + "slug": "packages-api-fetch", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/api-fetch/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/autop", + "slug": "packages-autop", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/autop/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-plugin-import-jsx-pragma", + "slug": "packages-babel-plugin-import-jsx-pragma", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/babel-plugin-import-jsx-pragma/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-plugin-makepot", + "slug": "packages-babel-plugin-makepot", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/babel-plugin-makepot/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/babel-preset-default", + "slug": "packages-babel-preset-default", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/babel-preset-default/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/blob", + "slug": "packages-blob", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/blob/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-editor", + "slug": "packages-block-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/block-editor/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-library", + "slug": "packages-block-library", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/block-library/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-serialization-default-parser", + "slug": "packages-block-serialization-default-parser", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/block-serialization-default-parser/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/block-serialization-spec-parser", + "slug": "packages-block-serialization-spec-parser", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/block-serialization-spec-parser/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/blocks", + "slug": "packages-blocks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/blocks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/browserslist-config", + "slug": "packages-browserslist-config", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/browserslist-config/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/components", + "slug": "packages-components", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/compose", + "slug": "packages-compose", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/compose/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/core-data", + "slug": "packages-core-data", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/core-data/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/custom-templated-path-webpack-plugin", + "slug": "packages-custom-templated-path-webpack-plugin", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/custom-templated-path-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/data-controls", + "slug": "packages-data-controls", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/data-controls/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/data", + "slug": "packages-data", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/data/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/date", + "slug": "packages-date", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/date/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dependency-extraction-webpack-plugin", + "slug": "packages-dependency-extraction-webpack-plugin", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/dependency-extraction-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/deprecated", + "slug": "packages-deprecated", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/deprecated/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/docgen", + "slug": "packages-docgen", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/docgen/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dom-ready", + "slug": "packages-dom-ready", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/dom-ready/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/dom", + "slug": "packages-dom", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/dom/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/e2e-test-utils", + "slug": "packages-e2e-test-utils", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/e2e-test-utils/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/e2e-tests", + "slug": "packages-e2e-tests", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/e2e-tests/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/edit-post", + "slug": "packages-edit-post", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/edit-post/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/edit-widgets", + "slug": "packages-edit-widgets", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/edit-widgets/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/editor", + "slug": "packages-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/editor/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/element", + "slug": "packages-element", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/element/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/escape-html", + "slug": "packages-escape-html", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/escape-html/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/eslint-plugin", + "slug": "packages-eslint-plugin", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/eslint-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/format-library", + "slug": "packages-format-library", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/format-library/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/hooks", + "slug": "packages-hooks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/hooks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/html-entities", + "slug": "packages-html-entities", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/html-entities/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/i18n", + "slug": "packages-i18n", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/i18n/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/is-shallow-equal", + "slug": "packages-is-shallow-equal", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/is-shallow-equal/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-console", + "slug": "packages-jest-console", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/jest-console/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-preset-default", + "slug": "packages-jest-preset-default", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/jest-preset-default/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/jest-puppeteer-axe", + "slug": "packages-jest-puppeteer-axe", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/jest-puppeteer-axe/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/keycodes", + "slug": "packages-keycodes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/keycodes/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/library-export-default-webpack-plugin", + "slug": "packages-library-export-default-webpack-plugin", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/library-export-default-webpack-plugin/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/list-reusable-blocks", + "slug": "packages-list-reusable-blocks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/list-reusable-blocks/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/media-utils", + "slug": "packages-media-utils", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/media-utils/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/notices", + "slug": "packages-notices", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/notices/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/npm-package-json-lint-config", + "slug": "packages-npm-package-json-lint-config", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/npm-package-json-lint-config/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/nux", + "slug": "packages-nux", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/nux/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/plugins", + "slug": "packages-plugins", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/plugins/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/postcss-themes", + "slug": "packages-postcss-themes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/postcss-themes/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/priority-queue", + "slug": "packages-priority-queue", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/priority-queue/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/redux-routine", + "slug": "packages-redux-routine", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/redux-routine/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/rich-text", + "slug": "packages-rich-text", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/rich-text/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/scripts", + "slug": "packages-scripts", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/scripts/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/shortcode", + "slug": "packages-shortcode", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/shortcode/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/token-list", + "slug": "packages-token-list", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/token-list/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/url", + "slug": "packages-url", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/url/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/viewport", + "slug": "packages-viewport", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/viewport/README.md", + "parent": "packages" + }, + { + "title": "@wordpress/wordcount", + "slug": "packages-wordcount", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/wordcount/README.md", + "parent": "packages" + }, + { + "title": "Components", + "slug": "components", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/README.md", + "parent": "developers" + }, + { + "title": "Animate", + "slug": "animate", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/animate/README.md", + "parent": "components" + }, + { + "title": "Autocomplete", + "slug": "autocomplete", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/autocomplete/README.md", + "parent": "components" + }, + { + "title": "BaseControl", + "slug": "base-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/base-control/README.md", + "parent": "components" + }, + { + "title": "ButtonGroup", + "slug": "button-group", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/button-group/README.md", + "parent": "components" + }, + { + "title": "Button", + "slug": "button", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/button/README.md", + "parent": "components" + }, + { + "title": "CheckboxControl", + "slug": "checkbox-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/checkbox-control/README.md", + "parent": "components" + }, + { + "title": "ClipboardButton", + "slug": "clipboard-button", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/clipboard-button/README.md", + "parent": "components" + }, + { + "title": "ColorIndicator", + "slug": "color-indicator", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/color-indicator/README.md", + "parent": "components" + }, + { + "title": "ColorPalette", + "slug": "color-palette", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/color-palette/README.md", + "parent": "components" + }, + { + "title": "ColorPicker", + "slug": "color-picker", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/color-picker/README.md", + "parent": "components" + }, + { + "title": "Dashicon", + "slug": "dashicon", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/dashicon/README.md", + "parent": "components" + }, + { + "title": "DateTime", + "slug": "date-time", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/date-time/README.md", + "parent": "components" + }, + { + "title": "Disabled", + "slug": "disabled", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/disabled/README.md", + "parent": "components" + }, + { + "title": "Draggable", + "slug": "draggable", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/draggable/README.md", + "parent": "components" + }, + { + "title": "DropZone", + "slug": "drop-zone", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/drop-zone/README.md", + "parent": "components" + }, + { + "title": "DropdownMenu", + "slug": "dropdown-menu", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/dropdown-menu/README.md", + "parent": "components" + }, + { + "title": "Dropdown", + "slug": "dropdown", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/dropdown/README.md", + "parent": "components" + }, + { + "title": "ExternalLink", + "slug": "external-link", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/external-link/README.md", + "parent": "components" + }, + { + "title": "FocalPointPicker", + "slug": "focal-point-picker", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/focal-point-picker/README.md", + "parent": "components" + }, + { + "title": "FocusableIframe", + "slug": "focusable-iframe", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/focusable-iframe/README.md", + "parent": "components" + }, + { + "title": "FontSizePicker", + "slug": "font-size-picker", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/font-size-picker/README.md", + "parent": "components" + }, + { + "title": "FormFileUpload", + "slug": "form-file-upload", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/form-file-upload/README.md", + "parent": "components" + }, + { + "title": "FormToggle", + "slug": "form-toggle", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/form-toggle/README.md", + "parent": "components" + }, + { + "title": "FormTokenField", + "slug": "form-token-field", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/form-token-field/README.md", + "parent": "components" + }, + { + "title": "NavigateRegions", + "slug": "navigate-regions", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/navigate-regions/README.md", + "parent": "components" + }, + { + "title": "HigherOrder", + "slug": "higher-order", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/README.md", + "parent": "components" + }, + { + "title": "WithConstrainedTabbing", + "slug": "with-constrained-tabbing", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-constrained-tabbing/README.md", + "parent": "components" + }, + { + "title": "WithFallbackStyles", + "slug": "with-fallback-styles", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-fallback-styles/README.md", + "parent": "components" + }, + { + "title": "WithFilters", + "slug": "with-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-filters/README.md", + "parent": "components" + }, + { + "title": "WithFocusOutside", + "slug": "with-focus-outside", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-focus-outside/README.md", + "parent": "components" + }, + { + "title": "WithFocusReturn", + "slug": "with-focus-return", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-focus-return/README.md", + "parent": "components" + }, + { + "title": "WithNotices", + "slug": "with-notices", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-notices/README.md", + "parent": "components" + }, + { + "title": "WithSpokenMessages", + "slug": "with-spoken-messages", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/higher-order/with-spoken-messages/README.md", + "parent": "components" + }, + { + "title": "IconButton", + "slug": "icon-button", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/icon-button/README.md", + "parent": "components" + }, + { + "title": "Icon", + "slug": "icon", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/icon/README.md", + "parent": "components" + }, + { + "title": "IsolatedEventContainer", + "slug": "isolated-event-container", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/isolated-event-container/README.md", + "parent": "components" + }, + { + "title": "KeyboardShortcuts", + "slug": "keyboard-shortcuts", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/keyboard-shortcuts/README.md", + "parent": "components" + }, + { + "title": "MenuGroup", + "slug": "menu-group", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/menu-group/README.md", + "parent": "components" + }, + { + "title": "MenuItem", + "slug": "menu-item", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/menu-item/README.md", + "parent": "components" + }, + { + "title": "MenuItemsChoice", + "slug": "menu-items-choice", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/menu-items-choice/README.md", + "parent": "components" + }, + { + "title": "Modal", + "slug": "modal", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/modal/README.md", + "parent": "components" + }, + { + "title": "NavigableContainer", + "slug": "navigable-container", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/navigable-container/README.md", + "parent": "components" + }, + { + "title": "Notice", + "slug": "notice", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/notice/README.md", + "parent": "components" + }, + { + "title": "Panel", + "slug": "panel", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/panel/README.md", + "parent": "components" + }, + { + "title": "Placeholder", + "slug": "placeholder", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/placeholder/README.md", + "parent": "components" + }, + { + "title": "Popover", + "slug": "popover", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/popover/README.md", + "parent": "components" + }, + { + "title": "HorizontalRule", + "slug": "horizontal-rule", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/primitives/horizontal-rule/README.md", + "parent": "components" + }, + { + "title": "Svg", + "slug": "svg", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/primitives/svg/README.md", + "parent": "components" + }, + { + "title": "QueryControls", + "slug": "query-controls", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/query-controls/README.md", + "parent": "components" + }, + { + "title": "RadioControl", + "slug": "radio-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/radio-control/README.md", + "parent": "components" + }, + { + "title": "RangeControl", + "slug": "range-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/range-control/README.md", + "parent": "components" + }, + { + "title": "ResizableBox", + "slug": "resizable-box", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/resizable-box/README.md", + "parent": "components" + }, + { + "title": "ResponsiveWrapper", + "slug": "responsive-wrapper", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/responsive-wrapper/README.md", + "parent": "components" + }, + { + "title": "Sandbox", + "slug": "sandbox", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/sandbox/README.md", + "parent": "components" + }, + { + "title": "ScrollLock", + "slug": "scroll-lock", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/scroll-lock/README.md", + "parent": "components" + }, + { + "title": "SelectControl", + "slug": "select-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/select-control/README.md", + "parent": "components" + }, + { + "title": "ServerSideRender", + "slug": "server-side-render", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/server-side-render/README.md", + "parent": "components" + }, + { + "title": "SlotFill", + "slug": "slot-fill", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/slot-fill/README.md", + "parent": "components" + }, + { + "title": "Snackbar", + "slug": "snackbar", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/snackbar/README.md", + "parent": "components" + }, + { + "title": "Spinner", + "slug": "spinner", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/spinner/README.md", + "parent": "components" + }, + { + "title": "TabPanel", + "slug": "tab-panel", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/tab-panel/README.md", + "parent": "components" + }, + { + "title": "TextControl", + "slug": "text-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/text-control/README.md", + "parent": "components" + }, + { + "title": "TextareaControl", + "slug": "textarea-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/textarea-control/README.md", + "parent": "components" + }, + { + "title": "ToggleControl", + "slug": "toggle-control", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/toggle-control/README.md", + "parent": "components" + }, + { + "title": "Toolbar", + "slug": "toolbar", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/toolbar/README.md", + "parent": "components" + }, + { + "title": "Tooltip", + "slug": "tooltip", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/tooltip/README.md", + "parent": "components" + }, + { + "title": "TreeSelect", + "slug": "tree-select", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/tree-select/README.md", + "parent": "components" + }, + { + "title": "Theming for the Block Editor", + "slug": "themes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/themes/README.md", + "parent": "developers" + }, + { + "title": "Theme Support", + "slug": "theme-support", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/themes/theme-support.md", + "parent": "themes" + }, + { + "title": "Backward Compatibility", + "slug": "backward-compatibility", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backward-compatibility/README.md", + "parent": "developers" + }, + { + "title": "Deprecations", + "slug": "deprecations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backward-compatibility/deprecations.md", + "parent": "backward-compatibility" + }, + { + "title": "Meta Boxes", + "slug": "meta-box", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backward-compatibility/meta-box.md", + "parent": "backward-compatibility" + }, + { + "title": "Tutorials", + "slug": "tutorials", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/readme.md", + "parent": "developers" + }, + { + "title": "Getting Started with JavaScript", + "slug": "javascript", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/readme.md", + "parent": "tutorials" + }, + { + "title": "Plugins Background", + "slug": "plugins-background", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/plugins-background.md", + "parent": "javascript" + }, + { + "title": "Loading JavaScript", + "slug": "loading-javascript", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md", + "parent": "javascript" + }, + { + "title": "Extending the Block Editor", + "slug": "extending-the-block-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md", + "parent": "javascript" + }, + { + "title": "Troubleshooting", + "slug": "troubleshooting", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md", + "parent": "javascript" + }, + { + "title": "JavaScript Versions and Build Step", + "slug": "versions-and-building", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/versions-and-building.md", + "parent": "javascript" + }, + { + "title": "Scope Your Code", + "slug": "scope-your-code", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/scope-your-code.md", + "parent": "javascript" + }, + { + "title": "JavaScript Build Setup", + "slug": "js-build-setup", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md", + "parent": "javascript" + }, + { + "title": "Blocks", + "slug": "block-tutorial", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/readme.md", + "parent": "tutorials" + }, + { + "title": "Writing Your First Block Type", + "slug": "writing-your-first-block-type", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md", + "parent": "block-tutorial" + }, + { + "title": "Applying Styles From a Stylesheet", + "slug": "applying-styles-with-stylesheets", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md", + "parent": "block-tutorial" + }, + { + "title": "Introducing Attributes and Editable Fields", + "slug": "introducing-attributes-and-editable-fields", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md", + "parent": "block-tutorial" + }, + { + "title": "Block Controls: Block Toolbar and Settings Sidebar", + "slug": "block-controls-toolbar-and-sidebar", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md", + "parent": "block-tutorial" + }, + { + "title": "Creating dynamic blocks", + "slug": "creating-dynamic-blocks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md", + "parent": "block-tutorial" + }, + { + "title": "Generate Blocks with WP-CLI", + "slug": "generate-blocks-with-wp-cli", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md", + "parent": "block-tutorial" + }, + { + "title": "Meta Boxes", + "slug": "metabox", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/readme.md", + "parent": "tutorials" + }, + { + "title": "Store Post Meta with a Block", + "slug": "meta-block-1-intro", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md", + "parent": "metabox" + }, + { + "title": "Register Meta Field", + "slug": "meta-block-2-register-meta", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md", + "parent": "metabox" + }, + { + "title": "Create Meta Block", + "slug": "meta-block-3-add", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md", + "parent": "metabox" + }, + { + "title": "Use Post Meta Data", + "slug": "meta-block-4-use-data", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md", + "parent": "metabox" + }, + { + "title": "Finishing Touches", + "slug": "meta-block-5-finishing", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md", + "parent": "metabox" + }, + { + "title": "Displaying Notices from Your Plugin or Theme", + "slug": "notices", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/notices/README.md", + "parent": "tutorials" + }, + { + "title": "Creating a Sidebar for Your Plugin", + "slug": "plugin-sidebar-0", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md", + "parent": "tutorials" + }, + { + "title": "Get a Sidebar up and Running", + "slug": "plugin-sidebar-1-up-and-running", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Tweak the sidebar style and add controls", + "slug": "plugin-sidebar-2-styles-and-controls", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Register the Meta Field", + "slug": "plugin-sidebar-3-register-meta", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Initialize the Input Control", + "slug": "plugin-sidebar-4-initialize-input", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Update the Meta Field When the Input's Content Changes", + "slug": "plugin-sidebar-5-update-meta", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Finishing Touches", + "slug": "plugin-sidebar-6-finishing-touches", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md", + "parent": "plugin-sidebar-0" + }, + { + "title": "Introduction to the Format API", + "slug": "format-api", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/format-api/README.md", + "parent": "tutorials" + }, + { + "title": "Register a New Format", + "slug": "1-register-format", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/format-api/1-register-format.md", + "parent": "format-api" + }, + { + "title": "Add a Button to the Toolbar", + "slug": "2-toolbar-button", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md", + "parent": "format-api" + }, + { + "title": "Apply the Format When the Button Is Clicked", + "slug": "3-apply-format", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/tutorials/format-api/3-apply-format.md", + "parent": "format-api" + }, + { + "title": "Designer Documentation", + "slug": "designers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/README.md", + "parent": "designers-developers" + }, + { + "title": "Block Design", + "slug": "block-design", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/block-design.md", + "parent": "designers" + }, + { + "title": "Patterns", + "slug": "design-patterns", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/design-patterns.md", + "parent": "designers" + }, + { + "title": "Resources", + "slug": "design-resources", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/design-resources.md", + "parent": "designers" + }, + { + "title": "Animation", + "slug": "animation", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/animation.md", + "parent": "designers" + }, + { + "title": "Contributors Guide", + "slug": "contributors", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/readme.md", + "parent": null + }, + { + "title": "Principles", + "slug": "principles", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/principles.md", + "parent": "contributors" + }, + { + "title": "Design Principles & Vision", + "slug": "design", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/design.md", + "parent": "contributors" + }, + { + "title": "Blocks are the Interface", + "slug": "the-block", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/principles/the-block.md", + "parent": "design" + }, + { + "title": "Reference", + "slug": "reference", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/reference.md", + "parent": "design" + }, + { + "title": "Developer Contributions", + "slug": "develop", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/develop.md", + "parent": "contributors" + }, + { + "title": "Getting Started", + "slug": "getting-started", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/getting-started.md", + "parent": "develop" + }, + { + "title": "Git Workflow", + "slug": "git-workflow", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/git-workflow.md", + "parent": "develop" + }, + { + "title": "Coding Guidelines", + "slug": "coding-guidelines", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/coding-guidelines.md", + "parent": "develop" + }, + { + "title": "Testing Overview", + "slug": "testing-overview", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/testing-overview.md", + "parent": "develop" + }, + { + "title": "Block Grammar", + "slug": "grammar", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/grammar.md", + "parent": "develop" + }, + { + "title": "Scripts", + "slug": "scripts", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/scripts.md", + "parent": "develop" + }, + { + "title": "Managing Packages", + "slug": "managing-packages", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/managing-packages.md", + "parent": "develop" + }, + { + "title": "Gutenberg Release Process", + "slug": "release", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/release.md", + "parent": "develop" + }, + { + "title": "Localizing Gutenberg Plugin", + "slug": "localizing", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/localizing.md", + "parent": "develop" + }, + { + "title": "Documentation Contributions", + "slug": "document", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/document.md", + "parent": "contributors" + }, + { + "title": "Copy Guidelines", + "slug": "copy-guide", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/copy-guide.md", + "parent": "document" + }, + { + "title": "History", + "slug": "history", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/history.md", + "parent": "contributors" + }, + { + "title": "Glossary", + "slug": "glossary", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/glossary.md", + "parent": "contributors" + }, + { + "title": "Frequently Asked Questions", + "slug": "faq", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/faq.md", + "parent": "contributors" + }, + { + "title": "Repository Management", + "slug": "repository-management", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/repository-management.md", + "parent": "contributors" + }, + { + "title": "Outreach", + "slug": "outreach", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/contributors/outreach.md", + "parent": "contributors" + } +] diff --git a/gb-src/docs/readme.md b/gb-src/docs/readme.md new file mode 100644 index 0000000000000..0f7817cd0b5a3 --- /dev/null +++ b/gb-src/docs/readme.md @@ -0,0 +1,11 @@ +# Block Editor Handbook + +The Gutenberg project is transforming the way content is created on WordPress. A block editor was the first product launched creating a new methodology for working with content. This handbook provides documentation for how designers and developers can extend the editor, and also how you can start contributing to the project. + +![Gutenberg Demo](https://cldup.com/kZXGDcGPMU.gif) + +Using a system of Blocks to compose and format content, the new block-based editor is designed to create rich, flexible layouts for websites and digital products. Content is created in the unit of blocks instead of freeform text with inserted media, embeds and Shortcodes (there's a Shortcode block though). + +Blocks treat Paragraphs, Headings, Media, Embeds all as components that strung together make up the content stored in the WordPress database, replacing the traditional concept of freeform text with embedded media and shortcodes. The new editor is designed with progressive enhancement, meaning it is back-compatible with all legacy content, offers a process to try to convert and split a Classic block into block equivalents using client-side parsing and finally the blocks offer enhanced editing and format controls. + +The Editor offers rich new value to users with visual, drag-and-drop creation tools and powerful developer enhancements with modern vendor packages, reusable components, rich APIs and hooks to modify and extend the editor through Custom Blocks, Custom Block Styles and Plugins. diff --git a/gb-src/docs/rfc/block-registration.md b/gb-src/docs/rfc/block-registration.md new file mode 100644 index 0000000000000..6c5f3ce2dfe0d --- /dev/null +++ b/gb-src/docs/rfc/block-registration.md @@ -0,0 +1,439 @@ +# Block Type Registration RFC + +This RFC is intended to serve both as a specification and as documentation for the implementation of runtime-agnostic block type registration. + +## Requirements + +Behind any block type registration is some abstract concept of a unit of content. This content type can be described without consideration of any particular technology. In much the same way, we should be able to describe the core constructs of a block type in a way which can be interpreted in any runtime. + +In more practical terms, an implementation should fulfill requirements that... + +* A block type registration should be declarative and context-agnostic. Any runtime (PHP, JS, or other) should be able to interpret the basics of a block type (see "Block API" in the sections below) and should be able to fetch or retrieve the definitions of the context-specific implementation details. The following things should be made possible: + * Fetching the available block types through REST APIs. + * Fetching block objects from posts through REST APIs. +* This API should be backward compatible with what we have at the moment. +* It should be possible to statically analyze a block type in order to support advanced use-cases required by one of the [9 projects](https://make.wordpress.org/core/2018/12/08/9-priorities-for-2019/) for 2019 in WordPress: "Building a WordPress.org directory for discovering blocks, and a way to seamlessly install them.". The block directory should not need to parse JavaScript or PHP files to retrieve their definitions similar to how it happens for plugins as of today. + +It can statically analyze the files of any plugin to retrieve blocks and their properties. +* It should not require a build tool compilation step (e.g. Babel, Webpack) to author code which would be referenced in a block type definition. +* There should allow the potential to dynamically load ("lazy-load") block types, or parts of block type definitions. It practical terms, it means that the editor should be able to be loaded without enqueuing all the assets (scripts and styles) of all block types. What it needs is the basic metadata (`title`, `description`, `category`, `icon`, etc...) to start with. It should be fine to defer loading all other code (`edit`, `save`, `transforms`, and other JavaScript implementations) until it is explicitly used (inserted into the post content). + +## References + +* Issue: [Block API: Server-side awareness of block types](https://github.com/WordPress/gutenberg/issues/2751) +* Follow-up issue: [Expose available blocks via an API](https://github.com/WordPress/gutenberg/issues/4116) +* Current documentation: [/docs/designers-developers/developers/block-api/block-registration.md](/docs/designers-developers/developers/block-api/block-registration.md) +* Make WordPress.org post: [The Block Directory, and a new type of plugin](https://make.wordpress.org/meta/2019/03/08/the-block-directory-and-a-new-type-of-plugin/) + + +## Previous attempts + +Initial support for server-defined block attributes was merged as part of [#2529](https://github.com/WordPress/gutenberg/pull/2529). PHP block type registrations are merged with those defined in the JavaScript runtime. While this enabled blocks to be defined within PHP, the majority of block types continue to be defined within JavaScript alone. The support was reserved for the exclusive use of [dynamic block types](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md), in large part because [`edit` and `save` behaviors](/docs/designers-developers/developers/block-api/block-edit-save.md) must still be implemented in JavaScript, and because a solution hadn't been considered for how to create individual block bundles during the build process, nor how to load such bundles efficiently if it were to come to be implemented. + +A demonstration for how block registration could be made filterable in PHP was explored in [#5802](https://github.com/WordPress/gutenberg/pull/5802). The purpose here was to explore how plugins could have better control over the registration. + +Another exploration in [#5652](https://github.com/WordPress/gutenberg/pull/5652) considered using JSON as a file format to share block type definitions between JavaScript and PHP. + +### Conclusions + +* The current approaches to client-side block type registration cannot support the proposed requirement to have all block types known outside the browser context. +* Using a statically-defined, JSON-formatted block type definition enables easy integration in both JavaScript and PHP runtimes. +* Registering a block type in PHP would allow for attribute default values to be assigned as dynamically generated from some external state (e.g. a database value, or localized string). +* By default, JSON does not support localization or dynamic values. +* On the server, a block type `icon` property can only be assigned as a string and thus cannot support SVGs and component-based icons. + +--- + +## Introduction + +Blocks are the fundamental elements of the editor. They are the primary way in which plugins and themes can register their own functionality and extend the capabilities of the editor. + +## Registering a block type + +To register a new block type, start by creating a `block.json` file. This file: + +* Gives a name to the block type. +* Defines some important metadata about the registered block type (title, category, icon, description, keywords). +* Defines the attributes of the block type. +* Registers all the scripts and styles for your block type. + +**Example:** + + +```json +{ + "name": "my-plugin/notice", + "title": "Notice", + "category": "common", + "parent": [ "core/group" ], + "icon": "star", + "description": "Shows warning, error or success notices ...", + "keywords": [ "alert", "message" ], + "textDomain": "my-plugin", + "attributes": { + "message": { + "type": "string", + "source": "html", + "selector": ".message" + } + }, + "styleVariations": [ + { "name": "default", "label": "Default", "isDefault": true }, + { "name": "other", "label": "Other" } + ], + "editorScript": "build/editor.js", + "script": "build/main.js", + "editorStyle": "build/editor.css", + "style": "build/style.css" +} +``` + +## Block API + +This section describes all the properties that can be added to the `block.json` file to define the behavior and metadata of block types. + +### Name + +* Type: `string` +* Required +* Localized: No +* Property: `name` + +```json +{ "name": "core/heading" } +``` + +The name for a block is a unique string that identifies a block. Names have to be structured as `namespace/block-name`, where namespace is the name of your plugin or theme. + +**Note:** A block name can only contain lowercase alphanumeric characters, dashes, and at most one forward slash to designate the plugin-unique namespace prefix. It must begin with a letter. + +**Note:** This name is used on the comment delimiters as `<!-- wp:my-plugin/book -->`. Block types in the `core` namespace do not include a namespace when serialized. + +### Title + +* Type: `string` +* Required +* Localized: Yes +* Property: `title` + +```json +{ "title": "Heading" } +``` + +This is the display title for your block, which can be translated with our translation functions. The block inserter will show this name. + +### Category + +* Type: `string` +* Required +* Localized: No +* Property: `category` + +```json +{ "category": "common" } +``` + +Blocks are grouped into categories to help users browse and discover them. + +The core provided categories are: + +* common +* formatting +* layout +* widgets +* embed + +Plugins and Themes can also register [custom block categories](/docs/designers-developers/developers/filters/block-filters.md#managing-block-categories). + +An implementation should expect and tolerate unknown categories, providing some reasonable fallback behavior (e.g. a "common" category). + +### Parent + +* Type: `string[]` +* Optional +* Localized: No +* Property: `parent` + +```json +{ "parent": [ "my-block/product" ] } +``` + +Setting `parent` lets a block require that it is only available when nested within the specified blocks. For example, you might want to allow an 'Add to Cart' block to only be available within a 'Product' block. + +### Icon + +* Type: `string` +* Optional +* Localized: No +* Property: `icon` + +```json +{ "icon": "smile" } +``` + +An icon property should be specified to make it easier to identify a block. These can be any of WordPress' Dashicons (slug serving also as a fallback in non-js contexts). + +**Note:** It's also possible to override this property on the client-side with the source of the SVG element. In addition, this property can be defined with JavaScript as an object containing background and foreground colors. This colors will appear with the icon when they are applicable e.g.: in the inserter. Custom SVG icons are automatically wrapped in the [wp.components.SVG](/packages/components/src/primitives/svg/README.md) component to add accessibility attributes (aria-hidden, role, and focusable). + +### Description + +* Type: `string` +* Optional +* Localized: Yes +* Property: `description` + +```json + { "description": "Introduce new sections and organize content to help visitors" } +``` + +This is a short description for your block, which can be translated with our translation functions. This will be shown in the block inspector. + +### Keywords + +* Type: `string[]` +* Optional +* Localized: Yes +* Property: `keywords` + +```json +{ "keywords": [ "keyword1", "keyword2" ] } +``` + +Sometimes a block could have aliases that help users discover it while searching. For example, an image block could also want to be discovered by photo. You can do so by providing an array of unlimited terms (which are translated). + +### Text Domain + +* Type: `string` +* Optional +* Localized: No +* Property: `textDomain` + +```json +{ "textDomain": "my-plugin" } +``` + +The [gettext](https://www.gnu.org/software/gettext/) text domain of the plugin/block. More information can be found in the [Text Domain](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/#text-domains) section of the [How to Internationalize your Plugin](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) page. + +### Attributes + +* Type: `object` +* Optional +* Localized: No +* Property: `attributes` + +```json +{ + "attributes": { + "cover": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "author": { + "type": "string", + "source": "html", + "selector": ".book-author" + } + } +} +``` + +Attributes provide the structured data needs of a block. They can exist in different forms when they are serialized, but they are declared together under a common interface. + +See the [the attributes documentation](/docs/designers-developers/developers/block-api/block-attributes.md) for more details. + +### Style Variations + +* Type: `array` +* Optional +* Localized: Yes (`label`) +* Property: `styles` +* Alias: `styleVariations` + +```json +{ + "styleVariations": [ + { "name": "default", "label": "Default", "isDefault": true }, + { "name": "other", "label": "Other" } + ] +} +``` + +Block styles can be used to provide alternative styles to block. It works by adding a class name to the block's wrapper. Using CSS, a theme developer can target the class name for the style variation if it is selected. + +Plugins and Themes can also register [custom block style](/docs/designers-developers/developers/filters/block-filters.md#block-style-variations) for existing blocks. + +### Editor Script + +* Type: `string` ([WPDefinedAsset](#WPDefinedAsset)) +* Optional +* Localized: No +* Property: `editorScript` + +```json +{ "editorScript": "build/editor.js" } +``` + +Block type editor script definition. It will only be enqueued in the context of the editor. + +### Script + +* Type: `string` ([WPDefinedAsset](#WPDefinedAsset)) +* Optional +* Localized: No +* Property: `script` + +```json +{ "script": "build/main.js" } +``` + +Block type frontend script definition. It will be enqueued both in the editor and when viewing the content on the front of the site. + +### Editor Style + +* Type: `string` ([WPDefinedAsset](#WPDefinedAsset)) +* Optional +* Localized: No +* Property: `editorStyle` + +```json +{ "editorStyle": "build/editor.css" } +``` + +Block type editor style definition. It will only be enqueued in the context of the editor. + +### Style + +* Type: `string` ([WPDefinedAsset](#WPDefinedAsset)) +* Optional +* Localized: No +* Property: `style` + +```json +{ "style": "build/style.css" } +``` + +Block type frontend style definition. It will be enqueued both in the editor and when viewing the content on the front of the site. + +## Backward compatibility + +The following properties are going to be supported for backward compatibility reasons on the client-side only. Some of them might be replaced with alternative APIs in the future: + - `edit` - see the [Edit and Save](/docs/designers-developers/developers/block-api/block-edit-save.md) documentation for more details. + - `save` - see the [Edit and Save](/docs/designers-developers/developers/block-api/block-edit-save.md) documentation for more details. + - `transforms` - see the [Transforms](/docs/designers-developers/developers/block-api/block-registration.md#transforms-optional) documentation for more details. + - `deprecated` - see the [Deprecated Blocks](/docs/designers-developers/developers/block-api/block-deprecation.md) documentation for more details. + - `supports` - see the [block supports](/docs/designers-developers/developers/block-api/block-registration.md#supports-optional) documentation page for more details. + - `merge` - undocumented as of today. Its role is to handle merging multiple blocks into one. + - `getEditWrapperProps` - undocumented as well. Its role is to inject additional props to the block edit's component wrapper. + +**Example**: +```js +wp.blocks.registerBlockType( 'my-block/name', { + edit: function() { + // Edit definition goes here. + }, + save: function() { + // Save definition goes here. + }, + supports: { + html: false + } +} ); +``` + +In the case of [dynamic blocks](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md) supported by WordPress, it should be still possible to register `render_callback` property using [`register_block_type`](https://developer.wordpress.org/reference/functions/register_block_type/) function on the server. + +## Assets + +### `WPDefinedAsset` + +The `WPDefinedAsset` type is a subtype of string, where the value must represent an absolute or relative path to a JavaScript or CSS file. + +**Example:** + +In `block.json`: +```json +{ "editorScript": "build/editor.js" } +``` + +#### WordPress context + +In the context of WordPress, when a block is registered with PHP, it will automatically register all scripts and styles that are found in the `block.json` file. + +That's why, the `WPDefinedAsset` type has to offer a way to mirror also the shape of params necessary to register scripts and styles using [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) and [`wp_register_style`](https://developer.wordpress.org/reference/functions/wp_register_style/), and then assign these as handles associated with your block using the `script`, `style`, `editor_script`, and `editor_style` block type registration settings. + +It's possible to provide an object which takes the following shape: +- `handle` (`string`) - the name of the script. If omitted, it will be auto-generated. +- `dependencies` (`string[]`) - an array of registered script handles this script depends on. Default value: `[]`. +- `version` (`string`|`false`|`null`) - string specifying the script version number, if it has one, which is added to the URL as a query string for cache busting purposes. If the version is set to `false`, a version number is automatically added equal to current installed WordPress version. If set to `null`, no version is added. Default value: `false`. + +The definition is stored inside separate JSON file which ends with `.asset.json` and is located next to the JS/CSS file listed in `block.json`. WordPress will automatically detect this file through pattern matching. This option is the preferred one as it is expected it will become an option to auto-generate those asset files with `@wordpress/scripts` package. + +**Example:** + +``` +build/ +├─ editor.js +└─ editor.asset.json +``` + +In `block.json`: +```json +{ "editorScript": "build/editor.js" } +``` + +In `build/editor.asset.json`: +```json +{ + "handle": "my-plugin-notice-editor", + "dependencies": [ "wp-blocks","wp-element", "wp-i18n" ], + "version": "3.0.0" +} +``` + +## Internationalization + +Localized properties are automatically wrapped in `_x` function calls on the backend and the frontend of WordPress. These translations are added as an inline script to the `wp-block-library` script handle in WordPress core or to the plugin's script handle when it defines metadata definition. + +WordPress string discovery automatically translates these strings using the `textDomain` property specified in the `block.json` file. + +**Example:** + +```json +{ + "title": "My block", + "description": "My block is fantastic", + "keywords": [ "fantastic" ], + "textDomain": "my-plugin" +} +``` + +In JavaScript, with the help of a Babel plugin, this becomes: + +```js +const metadata = { + title: _x( 'My block', 'block title', 'my-plugin' ), + description: _x( 'My block is fantastic', 'block description', 'my-plugin' ), + keywords: [ _x( 'fantastic', 'block keywords', 'my-plugin' ) ], +} +``` + +In PHP, it is transformed at runtime to code roughly equivalent to: + +```php +<?php +$metadata = array( + 'title' => _x( 'My block', 'block title', 'my-plugin' ), + 'description' => _x( 'My block is fantastic!', 'block description', 'my-plugin' ), + 'keywords' => array( _x( 'fantastic', 'block keywords', 'my-plugin' ) ), +); +``` + +Implementation should follow the existing [get_plugin_data](https://codex.wordpress.org/Function_Reference/get_plugin_data) function which parses the plugin contents to retrieve the plugin’s metadata, and it applies translations dynamically. + +## Backward Compatibility + +The existing registration mechanism (both server side and frontend) will continue to work, it will serve as low-level implementation detail for the `block.json` based registration. + +Core Blocks will be migrated iteratively and third-party blocks will see warnings appearing in the console to encourage them to refactor the block registration API used. diff --git a/gb-src/docs/roadmap.md b/gb-src/docs/roadmap.md new file mode 100644 index 0000000000000..10b92083e2bd0 --- /dev/null +++ b/gb-src/docs/roadmap.md @@ -0,0 +1,31 @@ +# Upcoming Projects & Roadmap + +_Complementary to [Phase 2 Scope](https://github.com/WordPress/gutenberg/issues/13113)._ + +This document outlines some of the features currently in development or being considered for the project. It should not be confused with the product roadmap for WordPress itself, even if some areas naturally overlap. The main purpose of it is to give visibility to some of the key problems remaining to be solved and as an invitation for those wanting to collaborate on some of the more complex issues to learn about what needs help. + +Gutenberg is already in use by millions of sites through WordPress, so in order to make substantial changes to the design or updating specifications it is advisable to consider a discussion process ("Request for Comments") showing both an understanding of the impact, both positives and negatives, trade offs and opportunities. + +## Projects + +- **Block Registry** — define an entry point for block identification. ([See active RFC](https://github.com/WordPress/gutenberg/pull/13693).) +- **Live Component Library** — a place to visualize and interact with the UI components and block tools included in the packages. +- **Modular Editor** — allow loading the block editor in several contexts without a dependency to a post object. (Ongoing [pending tasks](https://github.com/WordPress/gutenberg/issues/14043).) +- **Better Validation** — continue to refine the mechanisms used in validating editor content. (See in depth overview at [#11440](https://github.com/WordPress/gutenberg/issues/11440) and [#7604](https://github.com/WordPress/gutenberg/issues/7604).) +- **Block Areas** — build support for areas of blocks that fall outside the content (including relationship with templates, registration, storage, and so on). ([See overview](https://github.com/WordPress/gutenberg/issues/13489).) +- **Multi-Block Editing** — allow modifying attributes of multiple blocks of the same kind at once. +- **Rich Text Roadmap** — continue to develop the capabilities of the rich text package. ([See overview](https://github.com/WordPress/gutenberg/issues/13778).) +- **Common Block Functionality** — coalesce into a preferred mechanism for creating and sharing chunks of functionality (block alignment, color tools, etc) across blocks with a simple and intuitive code interface. (Suggested exploration: React Hooks, [see overview](https://github.com/WordPress/gutenberg/issues/15450).) +- **Responsive Images** — propose mechanisms for handling flexible image sources that can be optimized for loading and takes into account their placement on a page (within main content, a column, sidebar, etc). +- **Async Loading** — propose a strategy for loading block code only when necessary in the editor without overhead for the developer or disrupting the user experience. +- **Styles** — continue to develop the mechanisms for managing block style variations and other styling solutions. (See overview at [#7551](https://github.com/WordPress/gutenberg/issues/7551) and [#9534](https://github.com/WordPress/gutenberg/issues/9534).) +- **Bundling Front-end Assets** — explore ways in which front-end styles for blocks could be assembled based on which blocks are used in a given page response. ([See overview](https://github.com/WordPress/gutenberg/issues/5445).) +- **Transforms API** — improve the transform API to allow advanced use-cases: support for async-transforms, access to the block editor settings and bring consistency between the different types of transforms. ([See related issue](https://github.com/WordPress/gutenberg/issues/14755).) + +## Timeline + +The projects outlined above indicate areas of interest but not necessarily development priorities. Sometimes, a product need will accelerate a resolution (as is the case of the block registry), other times community interest might be the driving force. + +- 2019 Q1: Block Registry — First phase. Required for plugin directory "meta" project. +- 2019 Q2: Modular Editor — Requirement for most of phase 2. +- 2019 Q3: Block Areas. diff --git a/gb-src/docs/toc.json b/gb-src/docs/toc.json new file mode 100644 index 0000000000000..8f42aa7d2fa71 --- /dev/null +++ b/gb-src/docs/toc.json @@ -0,0 +1,126 @@ +[ + { "docs/readme.md": [] }, + { "docs/designers-developers/key-concepts.md": [] }, + { "docs/designers-developers/developers/README.md": [ + { "docs/designers-developers/developers/block-api/README.md": [ + { "docs/designers-developers/developers/block-api/block-registration.md": [] }, + { "docs/designers-developers/developers/block-api/block-edit-save.md": [] }, + { "docs/designers-developers/developers/block-api/block-attributes.md": [] }, + {"docs/designers-developers/developers/block-api/block-deprecation.md": [] }, + { "docs/designers-developers/developers/block-api/block-templates.md": [] }, + { "docs/designers-developers/developers/block-api/block-annotations.md": [] } + ] }, + { "docs/designers-developers/developers/filters/README.md": [ + { "docs/designers-developers/developers/filters/block-filters.md": [] }, + { "docs/designers-developers/developers/filters/editor-filters.md": [] }, + { "docs/designers-developers/developers/filters/parser-filters.md": [] }, + { "docs/designers-developers/developers/filters/autocomplete-filters.md": [] } + ] }, + {"docs/designers-developers/developers/slotfills/README.md": [ + { "docs/designers-developers/developers/slotfills/plugin-block-settings-menu-item.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-document-setting-panel.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-more-menu-item.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-post-publish-panel.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-post-status-info.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-pre-publish-panel.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-sidebar.md": [] }, + { "docs/designers-developers/developers/slotfills/plugin-sidebar-more-menu-item.md": [] } + ]}, + { "docs/designers-developers/developers/richtext.md": [] }, + { "docs/designers-developers/developers/internationalization.md": [] }, + { "docs/designers-developers/developers/accessibility.md": [] }, + { "docs/designers-developers/developers/feature-flags.md": [] }, + { "docs/designers-developers/developers/themes/README.md": [ + { "docs/designers-developers/developers/themes/theme-support.md": [] } + ] }, + { "docs/designers-developers/developers/backward-compatibility/README.md": [ + { "docs/designers-developers/developers/backward-compatibility/deprecations.md": [] }, + { "docs/designers-developers/developers/backward-compatibility/meta-box.md": [] } + ] } + ] }, + { "docs/designers-developers/designers/README.md": [ + { "docs/designers-developers/designers/block-design.md": [] }, + { "docs/designers-developers/designers/design-patterns.md": [] }, + { "docs/designers-developers/designers/design-resources.md": [] }, + { "docs/designers-developers/designers/animation.md": [] } + ] }, + { "docs/contributors/readme.md": [ + { "docs/contributors/principles.md": [] }, + { "docs/contributors/design.md": [ + { "docs/contributors/principles/the-block.md": [] }, + { "docs/contributors/reference.md": [] } + ] }, + { "docs/contributors/develop.md": [ + { "docs/contributors/getting-started.md": [] }, + { "docs/contributors/git-workflow.md": [] }, + { "docs/contributors/coding-guidelines.md": [] }, + { "docs/contributors/testing-overview.md": [] }, + { "docs/contributors/grammar.md": [] }, + { "docs/contributors/scripts.md": [] }, + { "docs/contributors/managing-packages.md": [] }, + { "docs/contributors/release.md": [] }, + { "docs/contributors/localizing.md": [] } + ] }, + { "docs/contributors/document.md": [ + { "docs/contributors/copy-guide.md": [] } + ] }, + { "docs/contributors/history.md": [] }, + { "docs/designers-developers/glossary.md": [] }, + { "docs/designers-developers/faq.md": [] }, + { "docs/contributors/repository-management.md": [] }, + { "docs/contributors/outreach.md": [] } + ] }, + { "docs/designers-developers/developers/tutorials/readme.md": [ + { "docs/designers-developers/developers/tutorials/javascript/readme.md": [ + { "docs/designers-developers/developers/tutorials/javascript/plugins-background.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/loading-javascript.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/troubleshooting.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/versions-and-building.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/scope-your-code.md": [] }, + { "docs/designers-developers/developers/tutorials/javascript/js-build-setup.md": [] } + ] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/readme.md": [ + { "docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md": [] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md": [] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md": [] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md": [] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md": [] }, + { "docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md": [] } + ] }, + { "docs/designers-developers/developers/tutorials/metabox/readme.md": [ + { "docs/designers-developers/developers/tutorials/metabox/meta-block-1-intro.md": [] }, + { "docs/designers-developers/developers/tutorials/metabox/meta-block-2-register-meta.md": [] }, + { "docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md": [] }, + { "docs/designers-developers/developers/tutorials/metabox/meta-block-4-use-data.md": [] }, + { "docs/designers-developers/developers/tutorials/metabox/meta-block-5-finishing.md": [] } + ] }, + { "docs/designers-developers/developers/tutorials/notices/README.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-0.md": [ + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-1-up-and-running.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-2-styles-and-controls.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-3-register-meta.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-4-initialize-input.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-5-update-meta.md": [] }, + { "docs/designers-developers/developers/tutorials/sidebar-tutorial/plugin-sidebar-6-finishing-touches.md": [] } + ] }, + { "docs/designers-developers/developers/tutorials/format-api/README.md": [ + { "docs/designers-developers/developers/tutorials/format-api/1-register-format.md": [] }, + { "docs/designers-developers/developers/tutorials/format-api/2-toolbar-button.md": [] }, + { "docs/designers-developers/developers/tutorials/format-api/3-apply-format.md": [] } + ] } + ] }, + { "packages/components/README.md": "{{components}}" }, + { "docs/designers-developers/developers/data/README.md": [ + { "docs/designers-developers/developers/data/data-core.md": []}, + { "docs/designers-developers/developers/data/data-core-annotations.md": [] }, + { "docs/designers-developers/developers/data/data-core-blocks.md": [] }, + { "docs/designers-developers/developers/data/data-core-block-editor.md": [] }, + { "docs/designers-developers/developers/data/data-core-editor.md": [] }, + { "docs/designers-developers/developers/data/data-core-edit-post.md": [] }, + { "docs/designers-developers/developers/data/data-core-notices.md": [] }, + { "docs/designers-developers/developers/data/data-core-nux.md": [] }, + { "docs/designers-developers/developers/data/data-core-viewport.md": [] } + ] }, + { "docs/designers-developers/developers/packages.md": "{{packages}}" } +] diff --git a/gb-src/docs/tool/index.js b/gb-src/docs/tool/index.js new file mode 100644 index 0000000000000..cd0e8223f9a6b --- /dev/null +++ b/gb-src/docs/tool/index.js @@ -0,0 +1,21 @@ +/** + * Node dependencies + */ +const fs = require( 'fs' ); +const { join } = require( 'path' ); +const { execFileSync } = require( 'child_process' ); +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { getRootManifest } = require( './manifest' ); + +const tocFileInput = path.resolve( __dirname, '../toc.json' ); +const manifestOutput = path.resolve( __dirname, '../manifest-devhub.json' ); + +// Update data files from code +execFileSync( 'node', [ join( __dirname, 'update-data.js' ) ] ); + +// Process TOC file and generate manifest handbook +fs.writeFileSync( manifestOutput, JSON.stringify( getRootManifest( tocFileInput ), undefined, '\t' ) ); diff --git a/gb-src/docs/tool/manifest.js b/gb-src/docs/tool/manifest.js new file mode 100644 index 0000000000000..80537983bf011 --- /dev/null +++ b/gb-src/docs/tool/manifest.js @@ -0,0 +1,97 @@ +/** + * Node dependencies + */ +const { camelCase, nth, upperFirst } = require( 'lodash' ); +const fs = require( 'fs' ); +const glob = require( 'glob' ).sync; + +const baseRepoUrl = '..'; +const componentPaths = glob( 'packages/components/src/*/**/README.md' ); +const packagePaths = glob( 'packages/*/package.json' ).map( + ( fileName ) => fileName.split( '/' )[ 1 ] +); + +/** + * Generates the package manifest. + * + * @param {Array} packageFolderNames Package folder names. + * + * @return {Array} Manifest + */ +function getPackageManifest( packageFolderNames ) { + return packageFolderNames.map( ( folderName ) => { + const path = `${ baseRepoUrl }/packages/${ folderName }/README.md`; + return { + title: `@wordpress/${ folderName }`, + slug: `packages-${ folderName }`, + markdown_source: path, + parent: 'packages', + }; + } ); +} + +/** + * Generates the components manifest. + * + * @param {Array} paths Paths for all components + * + * @return {Array} Manifest + */ +function getComponentManifest( paths ) { + return paths.map( ( filePath ) => { + const slug = nth( filePath.split( '/' ), -2 ); + return { + title: upperFirst( camelCase( slug ) ), + slug, + markdown_source: `${ baseRepoUrl }/${ filePath }`, + parent: 'components', + }; + } ); +} + +function getRootManifest( tocFileName ) { + return generateRootManifestFromTOCItems( require( tocFileName ) ); +} + +function generateRootManifestFromTOCItems( items, parent = null ) { + let pageItems = []; + items.forEach( ( obj ) => { + const fileName = Object.keys( obj )[ 0 ]; + const children = obj[ fileName ]; + + let slug = nth( fileName.split( '/' ), -1 ).replace( '.md', '' ); + if ( 'readme' === slug.toLowerCase() ) { + slug = nth( fileName.split( '/' ), -2 ); + + // Special case - the root 'docs' readme needs the 'handbook' slug. + if ( parent === null && 'docs' === slug ) { + slug = 'handbook'; + } + } + let title = upperFirst( camelCase( slug ) ); + const markdownSource = fs.readFileSync( fileName, 'utf8' ); + const titleMarkdown = markdownSource.match( /^#\s(.+)$/m ); + if ( titleMarkdown ) { + title = titleMarkdown[ 1 ]; + } + + pageItems.push( { + title, + slug, + markdown_source: `${ baseRepoUrl }\/${ fileName }`, + parent, + } ); + if ( Array.isArray( children ) && children.length ) { + pageItems = pageItems.concat( generateRootManifestFromTOCItems( children, slug ) ); + } else if ( children === '{{components}}' ) { + pageItems = pageItems.concat( getComponentManifest( componentPaths ) ); + } else if ( children === '{{packages}}' ) { + pageItems = pageItems.concat( getPackageManifest( packagePaths ) ); + } + } ); + return pageItems; +} + +module.exports = { + getRootManifest, +}; diff --git a/gb-src/docs/tool/update-data.js b/gb-src/docs/tool/update-data.js new file mode 100644 index 0000000000000..59a45ac4d599b --- /dev/null +++ b/gb-src/docs/tool/update-data.js @@ -0,0 +1,51 @@ +/** + * Node dependencies. + */ +const { join } = require( 'path' ); +const spawnSync = require( 'child_process' ).spawnSync; + +const modules = [ + [ 'core', { + 'Autogenerated actions': 'packages/core-data/src/actions.js', + 'Autogenerated selectors': 'packages/core-data/src/selectors.js', + } ], + 'core/annotations', + 'core/blocks', + 'core/block-editor', + 'core/editor', + 'core/edit-post', + 'core/notices', + 'core/nux', + 'core/viewport', +]; + +modules.forEach( ( entry ) => { + if ( ! Array.isArray( entry ) ) { + entry = [ entry, { + 'Autogenerated actions': `packages/${ entry.replace( 'core/', '' ) }/src/store/actions.js`, + 'Autogenerated selectors': `packages/${ entry.replace( 'core/', '' ) }/src/store/selectors.js`, + } ]; + } + const [ namespace, targets ] = entry; + + Object.entries( targets ).forEach( ( [ token, target ] ) => { + // Note that this needs to be a sync process for each output file that is updated: + // until docgen provides a way to update many tokens at once, we need to make sure + // the output file is updated before starting the second pass for the next token. + const { status, stderr } = spawnSync( + join( __dirname, '..', '..', 'node_modules', '.bin', 'docgen' ), + [ + target, + `--output docs/designers-developers/developers/data/data-${ namespace.replace( '/', '-' ) }.md`, + '--to-token', + `--use-token "${ token }"`, + '--ignore "/unstable|experimental/i"', + ], + { shell: true }, + ); + + if ( status !== 0 ) { + throw stderr.toString(); + } + } ); +} ); diff --git a/gb-src/gutenberg.php b/gb-src/gutenberg.php new file mode 100644 index 0000000000000..ad3a2c9ea4ff0 --- /dev/null +++ b/gb-src/gutenberg.php @@ -0,0 +1,138 @@ +<?php +/** + * Plugin Name: Gutenberg + * Plugin URI: https://github.com/WordPress/gutenberg + * Description: Printing since 1440. This is the development plugin for the new block editor in core. + * Version: 6.5.0-rc.1 + * Author: Gutenberg Team + * Text Domain: gutenberg + * + * @package gutenberg + */ + +### BEGIN AUTO-GENERATED DEFINES +defined( 'GUTENBERG_DEVELOPMENT_MODE' ) or define( 'GUTENBERG_DEVELOPMENT_MODE', true ); +### END AUTO-GENERATED DEFINES + +gutenberg_pre_init(); + +/** + * Gutenberg's Menu. + * + * Adds a new wp-admin menu page for the Gutenberg editor. + * + * @since 0.1.0 + */ +function gutenberg_menu() { + global $submenu; + + add_menu_page( + 'Gutenberg', + 'Gutenberg', + 'edit_posts', + 'gutenberg', + '', + 'dashicons-edit' + ); + + add_submenu_page( + 'gutenberg', + __( 'Demo', 'gutenberg' ), + __( 'Demo', 'gutenberg' ), + 'edit_posts', + 'gutenberg' + ); + + if ( get_option( 'gutenberg-experiments' ) && array_key_exists( 'gutenberg-widget-experiments', get_option( 'gutenberg-experiments' ) ) ) { + add_submenu_page( + 'gutenberg', + __( 'Widgets (beta)', 'gutenberg' ), + __( 'Widgets (beta)', 'gutenberg' ), + 'edit_theme_options', + 'gutenberg-widgets', + 'the_gutenberg_widgets' + ); + } + + if ( current_user_can( 'edit_posts' ) ) { + $submenu['gutenberg'][] = array( + __( 'Support', 'gutenberg' ), + 'edit_posts', + __( 'https://wordpress.org/support/plugin/gutenberg', 'gutenberg' ), + ); + + $submenu['gutenberg'][] = array( + __( 'Documentation', 'gutenberg' ), + 'edit_posts', + 'https://developer.wordpress.org/block-editor/', + ); + } + + add_submenu_page( + 'gutenberg', + __( 'Experiments Settings', 'gutenberg' ), + __( 'Experiments', 'gutenberg' ), + 'edit_posts', + 'gutenberg-experiments', + 'the_gutenberg_experiments' + ); +} +add_action( 'admin_menu', 'gutenberg_menu' ); + +/** + * Display a version notice and deactivate the Gutenberg plugin. + * + * @since 0.1.0 + */ +function gutenberg_wordpress_version_notice() { + echo '<div class="error"><p>'; + /* translators: %s: Minimum required version */ + printf( __( 'Gutenberg requires WordPress %s or later to function properly. Please upgrade WordPress before activating Gutenberg.', 'gutenberg' ), '5.0.0' ); + echo '</p></div>'; + + deactivate_plugins( array( 'gutenberg/gutenberg.php' ) ); +} + +/** + * Display a build notice. + * + * @since 0.1.0 + */ +function gutenberg_build_files_notice() { + echo '<div class="error"><p>'; + _e( 'Gutenberg development mode requires files to be built. Run <code>npm install</code> to install dependencies, <code>npm run build</code> to build the files or <code>npm run dev</code> to build the files and watch for changes. Read the <a href="https://github.com/WordPress/gutenberg/blob/master/docs/contributors/getting-started.md">contributing</a> file for more information.', 'gutenberg' ); + echo '</p></div>'; +} + +/** + * Verify that we can initialize the Gutenberg editor , then load it. + * + * @since 1.5.0 + */ +function gutenberg_pre_init() { + if ( defined( 'GUTENBERG_DEVELOPMENT_MODE' ) && GUTENBERG_DEVELOPMENT_MODE && ! file_exists( dirname( __FILE__ ) . '/build/blocks' ) ) { + add_action( 'admin_notices', 'gutenberg_build_files_notice' ); + return; + } + + // Get unmodified $wp_version. + include ABSPATH . WPINC . '/version.php'; + + // Strip '-src' from the version string. Messes up version_compare(). + $version = str_replace( '-src', '', $wp_version ); + + if ( version_compare( $version, '5.0.0', '<' ) ) { + add_action( 'admin_notices', 'gutenberg_wordpress_version_notice' ); + return; + } + + require_once dirname( __FILE__ ) . '/lib/load.php'; +} + +/** + * Outputs a WP REST API nonce. + */ +function gutenberg_rest_nonce() { + exit( wp_create_nonce( 'wp_rest' ) ); +} +add_action( 'wp_ajax_gutenberg_rest_nonce', 'gutenberg_rest_nonce' ); diff --git a/gb-src/jsconfig.json b/gb-src/jsconfig.json new file mode 100644 index 0000000000000..51f5ce09b49e0 --- /dev/null +++ b/gb-src/jsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@wordpress/*": ["./*", "./packages/*/src"] + } + }, + "exclude": [ + "build", + "build-module", + "node_modules", + "packages/e2e-tests/plugins", + "vendor" + ] +} diff --git a/gb-src/lerna.json b/gb-src/lerna.json new file mode 100644 index 0000000000000..fd005a960f1e6 --- /dev/null +++ b/gb-src/lerna.json @@ -0,0 +1,16 @@ +{ + "command": { + "publish": { + "message": "chore(release): publish" + } + }, + "ignoreChanges": [ + "**/benchmark/*.js", + "**/CHANGELOG.md", + "**/test/**" + ], + "packages": [ + "packages/*" + ], + "version": "independent" +} diff --git a/gb-src/package-lock.json b/gb-src/package-lock.json new file mode 100644 index 0000000000000..da3e1adb8794f --- /dev/null +++ b/gb-src/package-lock.json @@ -0,0 +1,28356 @@ +{ + "name": "gutenberg", + "version": "6.6.0-rc.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@actions/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.0.0.tgz", + "integrity": "sha512-aMIlkx96XH4E/2YZtEOeyrYQfhlas9jIRkfGPqMwXD095Rdkzo4lB6ZmbxPQSzD+e1M+Xsm98ZhuSMYGv/AlqA==", + "dev": true + }, + "@actions/github": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-1.0.0.tgz", + "integrity": "sha512-PPbWZ5wFAD/Vr+RCECfR3KNHjTwYln4liJBihs9tQUL0/PCFqB2lSkIh9V94AcZFHxgKk8snImjuLaBE8bKR7A==", + "dev": true, + "requires": { + "@octokit/graphql": "^2.0.1", + "@octokit/rest": "^16.15.0" + } + }, + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/core": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.4.5.tgz", + "integrity": "sha512-OvjIh6aqXtlsA8ujtGKfC7LYWksYSX8yQcM8Ay3LuvVeQ63lcOKgoZWVqcpFwkd29aYU9rVx7jxhfhiEDV9MZA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.4.4", + "@babel/helpers": "^7.4.4", + "@babel/parser": "^7.4.5", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.4.5", + "@babel/types": "^7.4.4", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.11", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "@babel/parser": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.6.4.tgz", + "integrity": "sha512-D8RHPW5qd0Vbyo3qb+YjO5nvUVRTXFLQ/FsDxJU2Nqz4uB5EnUN0ZQSEYpvTIbRuttig1XbHWU5oMeQwQSAA+A==", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "json5": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.1.tgz", + "integrity": "sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.4.4.tgz", + "integrity": "sha512-53UOLK6TVNqKxf7RUh8NE851EHRxOOeVXKbK2bivdb+iziMyk03Sr4eaE9OELCbyZAAafAKPDwF2TPUES5QbxQ==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.11", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz", + "integrity": "sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz", + "integrity": "sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-builder-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz", + "integrity": "sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0", + "esutils": "^2.0.0" + } + }, + "@babel/helper-call-delegate": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz", + "integrity": "sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.4.4", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz", + "integrity": "sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-member-expression-to-functions": "^7.0.0", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.4.4", + "@babel/helper-split-export-declaration": "^7.4.4" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz", + "integrity": "sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz", + "integrity": "sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz", + "integrity": "sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz", + "integrity": "sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-module-transforms": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.4.4.tgz", + "integrity": "sha512-3Z1yp8TVQf+B4ynN7WoHPKS8EkdTbgAEy0nU0rs/1Kw4pDgmvYH3rz3aI11KgxKCba2cn7N+tqzV1mY2HMN96w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-simple-access": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/template": "^7.4.4", + "@babel/types": "^7.4.4", + "lodash": "^4.17.11" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz", + "integrity": "sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz", + "integrity": "sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.4.4.tgz", + "integrity": "sha512-Y5nuB/kESmR3tKjU8Nkn1wMGEx1tjJX076HBMeL3XLQCu6vA/YRzuTW0bbb+qRnXvQGn+d6Rx953yffl8vEy7Q==", + "dev": true, + "requires": { + "lodash": "^4.17.11" + } + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz", + "integrity": "sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-wrap-function": "^7.1.0", + "@babel/template": "^7.1.0", + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-replace-supers": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz", + "integrity": "sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.0.0", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-simple-access": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz", + "integrity": "sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w==", + "dev": true, + "requires": { + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", + "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-wrap-function": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz", + "integrity": "sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/template": "^7.1.0", + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.2.0" + } + }, + "@babel/helpers": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.4.4.tgz", + "integrity": "sha512-igczbR/0SeuPR8RFfC7tGrbdTbFL3QTvH6D+Z6zNxnTe//GyqmtHmDkzrqDmyZ3eSwPqB/LhyKoU5DXsp+Vp2A==", + "dev": true, + "requires": { + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/highlight": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz", + "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==", + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.4.5.tgz", + "integrity": "sha512-9mUqkL1FF5T7f0WDFfAoDdiMVPWsdD1gZYzSnaXsxUCUqzuch/8of9G3VUSNiZmMBoRxT3neyVsqeiL/ZPcjew==", + "dev": true + }, + "@babel/plugin-external-helpers": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-external-helpers/-/plugin-external-helpers-7.2.0.tgz", + "integrity": "sha512-QFmtcCShFkyAsNtdCM3lJPmRe1iB+vPZymlB4LnDIKEBj2yKQLQKtoxXxJ8ePT5fwMl4QGg303p4mB0UsSI2/g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz", + "integrity": "sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.1.0", + "@babel/plugin-syntax-async-generators": "^7.2.0" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.0.tgz", + "integrity": "sha512-9L/JfPCT+kShiiTTzcnBJ8cOwdKVmlC1RcCf9F0F9tERVrM4iWtWnXtjWCRqNm2la2BxO1MPArWNsU9zsSJWSQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.5.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-proposal-export-default-from": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.5.2.tgz", + "integrity": "sha512-wr9Itk05L1/wyyZKVEmXWCdcsp/e185WUNl6AfYZeEKYaUPPvHXRDqO5K1VH7/UamYqGJowFRuCv30aDYZawsg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-export-default-from": "^7.2.0" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz", + "integrity": "sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-json-strings": "^7.2.0" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.4.4.tgz", + "integrity": "sha512-Amph7Epui1Dh/xxUxS2+K22/MUi6+6JVTvy3P58tja3B6yKTSjwwx0/d83rF7551D6PVSSoplQb8GCwqec7HRw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.2.0" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.4.4.tgz", + "integrity": "sha512-dMBG6cSPBbHeEBdFXeQ2QLc5gUpg4Vkaz8octD4aoW/ISO+jBOcsuxYL7bsb5WSu8RLP6boxrBIALEHgoHtO9g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.2.0.tgz", + "integrity": "sha512-mgYj3jCcxug6KUcX4OBoOJz3CMrwRfQELPQ5560F70YQUBZB7uac9fqaWamKR1iWUzGiK2t0ygzjTScZnVz75g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz", + "integrity": "sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-chaining": "^7.2.0" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.4.tgz", + "integrity": "sha512-j1NwnOqMG9mFUOH58JTFsA/+ZYzQLUZ/drqWUqxCYLGeu2JFZL8YrNC9hBxKmWtAuOCHPcRpgv7fhap09Fb4kA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz", + "integrity": "sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.2.0.tgz", + "integrity": "sha512-UxYaGXYQ7rrKJS/PxIKRkv3exi05oH7rokBAsmCSsCxz1sVPZ7Fu6FzKoGgUvmY+0YgSkYHgUoCh5R5bCNBQlw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz", + "integrity": "sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-export-default-from": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.2.0.tgz", + "integrity": "sha512-c7nqUnNST97BWPtoe+Ssi+fJukc9P9/JMZ71IOMNQWza2E+Psrd46N6AEvtw6pqK+gt7ChjXyrw4SPDO79f3Lw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.2.0.tgz", + "integrity": "sha512-r6YMuZDWLtLlu0kqIim5o/3TNRAlWb073HwT3e2nKf9I8IIvOggPrnILYPsrrKilmn/mYEMCf/Z07w3yQJF6dg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz", + "integrity": "sha512-5UGYnMSLRE1dqqZwug+1LISpA403HzlSfsg6P9VXU6TBjcSHeNlw4DxDx7LgpF+iKZoOG/+uzqoRHTdcUpiZNg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz", + "integrity": "sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.2.0.tgz", + "integrity": "sha512-lRCEaKE+LTxDQtgbYajI04ddt6WW0WJq57xqkAZ+s11h4YgfRHhVA/Y2VhfPzzFD4qeLHWg32DMp9HooY4Kqlg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", + "integrity": "sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.2.0.tgz", + "integrity": "sha512-bDe4xKNhb0LI7IvZHiA13kff0KEfaGX/Hv4lMA9+7TEc63hMNvfKo6ZFpXhKuEp+II/q35Gc4NoMeDZyaUbj9w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz", + "integrity": "sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.3.3.tgz", + "integrity": "sha512-dGwbSMA1YhVS8+31CnPR7LB4pcbrzcV99wQzby4uAfrkZPYZlQ7ImwdpzLqi6Z6IL02b8IAL379CaMwo0x5Lag==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz", + "integrity": "sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.4.4.tgz", + "integrity": "sha512-YiqW2Li8TXmzgbXw+STsSqPBPFnGviiaSp6CYOq55X8GQ2SGVLrXB6pNid8HkqkZAzOH6knbai3snhP7v0fNwA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.1.0" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz", + "integrity": "sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.4.tgz", + "integrity": "sha512-jkTUyWZcTrwxu5DD4rWz6rDB5Cjdmgz6z7M7RLXOJyCUkFBawssDGcGh8M/0FTSB87avyJI1HsTwUXp9nKA1PA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "lodash": "^4.17.11" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.5.5.tgz", + "integrity": "sha512-U2htCNK/6e9K7jGyJ++1p5XRU+LJjrwtoiVn9SzRlDT2KubcZ11OOwy3s24TjHxPgxNwonCYP7U2K51uVYCMDg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-define-map": "^7.5.5", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.5.5", + "@babel/helper-split-export-declaration": "^7.4.4", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/generator": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz", + "integrity": "sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-define-map": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.5.5.tgz", + "integrity": "sha512-fTfxx7i0B5NJqvUOBBGREnrqbTxRh7zinBANpZXAVDlsZxYdclDp467G1sQ8VZYMnAURY3RpBUAgOYT9GfzHBg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/types": "^7.5.5", + "lodash": "^4.17.13" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz", + "integrity": "sha512-5qZ3D1uMclSNqYcXqiHoA0meVdv+xUEex9em2fqMnrk/scphGlGgg66zjMrPJESPwrFJ6sbfFQYUSa0Mz7FabA==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5" + } + }, + "@babel/helper-replace-supers": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz", + "integrity": "sha512-XvRFWrNnlsow2u7jXDuH4jDDctkxbS7gXssrP4q2nUD606ukXHRvydj346wmNg+zAgpFx4MWf4+usfC93bElJg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.5.5", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/traverse": "^7.5.5", + "@babel/types": "^7.5.5" + } + }, + "@babel/parser": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", + "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", + "dev": true + }, + "@babel/traverse": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.5.tgz", + "integrity": "sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.5.5", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.5.5", + "@babel/types": "^7.5.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.5.tgz", + "integrity": "sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz", + "integrity": "sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.4.4.tgz", + "integrity": "sha512-/aOx+nW0w8eHiEHm+BTERB2oJn5D127iye/SUQl7NjHy0lf+j7h4MKMMSOwdazGq9OxgiNADncE+SRJkCxjZpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.4.4.tgz", + "integrity": "sha512-P05YEhRc2h53lZDjRPk/OektxCVevFzZs2Gfjd545Wde3k+yFDbXORgl2e0xpbq8mLcKJ7Idss4fAg0zORN/zg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.2.0.tgz", + "integrity": "sha512-q+yuxW4DsTjNceUiTzK0L+AfQ0zD9rWaTLiUqHA8p0gxx7lu1EylenfzjeIWNkPy6e/0VG/Wjw9uf9LueQwLOw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz", + "integrity": "sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.3.4.tgz", + "integrity": "sha512-PmQC9R7DwpBFA+7ATKMyzViz3zCaMNouzZMPZN2K5PnbBbtL3AXFYTkDk+Hey5crQq2A90UG5Uthz0mel+XZrA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.2.0" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz", + "integrity": "sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz", + "integrity": "sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz", + "integrity": "sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.2.0.tgz", + "integrity": "sha512-HiU3zKkSU6scTidmnFJ0bMX8hz5ixC93b4MHMiYebmk2lUVNGOboPsqQvx5LzooihijUoLR/v7Nc1rbBtnc7FA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.2.0.tgz", + "integrity": "sha512-mK2A8ucqz1qhrdqjS9VMIDfIvvT2thrEsIQzbaTdc5QFzhDjQv2CkJJ5f6BXIkgbmaoax3zBr2RyvV/8zeoUZw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.4.4.tgz", + "integrity": "sha512-4sfBOJt58sEo9a2BQXnZq+Q3ZTSAUXyK3E30o36BOGnJ+tvJ6YSxF0PG6kERvbeISgProodWuI9UVG3/FMY6iw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.4.4", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-simple-access": "^7.1.0" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.4.4.tgz", + "integrity": "sha512-MSiModfILQc3/oqnG7NrP1jHaSPryO6tA2kOMmAQApz5dayPxWiHqmq4sWH2xF5LcQK56LlbKByCd8Aah/OIkQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.4.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.2.0.tgz", + "integrity": "sha512-BV3bw6MyUH1iIsGhXlOK6sXhmSarZjtJ/vMiD9dNmpY8QXFFQTj+6v92pcfy1iqa8DeAfJFwoxcrS/TUZda6sw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.4.4.tgz", + "integrity": "sha512-Ki+Y9nXBlKfhD+LXaRS7v95TtTGYRAf9Y1rTDiE75zf8YQz4GDaWRXosMfJBXxnk88mGFjWdCRIeqDbon7spYA==", + "dev": true, + "requires": { + "regexp-tree": "^0.1.0" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.4.4.tgz", + "integrity": "sha512-r1z3T2DNGQwwe2vPGZMBNjioT2scgWzK9BCnDEh+46z8EEwXBq24uRzd65I7pjtugzPSj921aM15RpESgzsSuA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-object-assign": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.2.0.tgz", + "integrity": "sha512-nmE55cZBPFgUktbF2OuoZgPRadfxosLOpSgzEPYotKSls9J4pEPcembi8r78RU37Rph6UApCpNmsQA4QMWK9Ng==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz", + "integrity": "sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.1.0" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz", + "integrity": "sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw==", + "dev": true, + "requires": { + "@babel/helper-call-delegate": "^7.4.4", + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.2.0.tgz", + "integrity": "sha512-9q7Dbk4RhgcLp8ebduOpCbtjh7C0itoLYHXd9ueASKAG/is5PQtMR5VJGka9NKqGhYEGn5ITahd4h9QeBMylWQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.2.0.tgz", + "integrity": "sha512-Htf/tPa5haZvRMiNSQSFifK12gtr/8vwfr+A9y69uF0QcU77AVu4K7MiHEkTxF7lQoHOL0F9ErqgfNEAKgXj7A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz", + "integrity": "sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg==", + "dev": true, + "requires": { + "@babel/helper-builder-react-jsx": "^7.3.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.2.0" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.5.0.tgz", + "integrity": "sha512-58Q+Jsy4IDCZx7kqEZuSDdam/1oW8OdDX8f+Loo6xyxdfg1yF0GE2XNJQSTZCaMol93+FBzpWiPEwtbMloAcPg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.2.0" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.4.tgz", + "integrity": "sha512-Zz3w+pX1SI0KMIiqshFZkwnVGUhDZzpX2vtPzfJBKQQq8WsP/Xy9DNdELWivxcKOCX/Pywge4SiEaPaLtoDT4g==", + "dev": true, + "requires": { + "regenerator-transform": "^0.13.4" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.2.0.tgz", + "integrity": "sha512-fz43fqW8E1tAB3DKF19/vxbpib1fuyCwSPE418ge5ZxILnBhWyhtPgz8eh1RCGGJlwvksHkyxMxh0eenFi+kFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.4.4.tgz", + "integrity": "sha512-aMVojEjPszvau3NRg+TIH14ynZLvPewH4xhlCW1w6A3rkxTS1m4uwzRclYR9oS+rl/dr+kT+pzbfHuAWP/lc7Q==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "resolve": "^1.8.1", + "semver": "^5.5.1" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", + "integrity": "sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz", + "integrity": "sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz", + "integrity": "sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.0.0" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz", + "integrity": "sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz", + "integrity": "sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.5.2.tgz", + "integrity": "sha512-r4zJOMbKY5puETm8+cIpaa0RQZG/sSASW1u0pj8qYklcERgVIbxVbP2wyJA7zI1//h7lEagQmXi9IL9iI5rfsA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.5.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-typescript": "^7.2.0" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz", + "integrity": "sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/preset-env": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.4.5.tgz", + "integrity": "sha512-f2yNVXM+FsR5V8UwcFeIHzHWgnhXg3NpRmy0ADvALpnhB0SLbCvrCRr4BLOUYbQNLS+Z0Yer46x9dJXpXewI7w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-json-strings": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.4.4", + "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-syntax-async-generators": "^7.2.0", + "@babel/plugin-syntax-json-strings": "^7.2.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.4.4", + "@babel/plugin-transform-block-scoped-functions": "^7.2.0", + "@babel/plugin-transform-block-scoping": "^7.4.4", + "@babel/plugin-transform-classes": "^7.4.4", + "@babel/plugin-transform-computed-properties": "^7.2.0", + "@babel/plugin-transform-destructuring": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/plugin-transform-duplicate-keys": "^7.2.0", + "@babel/plugin-transform-exponentiation-operator": "^7.2.0", + "@babel/plugin-transform-for-of": "^7.4.4", + "@babel/plugin-transform-function-name": "^7.4.4", + "@babel/plugin-transform-literals": "^7.2.0", + "@babel/plugin-transform-member-expression-literals": "^7.2.0", + "@babel/plugin-transform-modules-amd": "^7.2.0", + "@babel/plugin-transform-modules-commonjs": "^7.4.4", + "@babel/plugin-transform-modules-systemjs": "^7.4.4", + "@babel/plugin-transform-modules-umd": "^7.2.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.4.5", + "@babel/plugin-transform-new-target": "^7.4.4", + "@babel/plugin-transform-object-super": "^7.2.0", + "@babel/plugin-transform-parameters": "^7.4.4", + "@babel/plugin-transform-property-literals": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.4.5", + "@babel/plugin-transform-reserved-words": "^7.2.0", + "@babel/plugin-transform-shorthand-properties": "^7.2.0", + "@babel/plugin-transform-spread": "^7.2.0", + "@babel/plugin-transform-sticky-regex": "^7.2.0", + "@babel/plugin-transform-template-literals": "^7.4.4", + "@babel/plugin-transform-typeof-symbol": "^7.2.0", + "@babel/plugin-transform-unicode-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "browserslist": "^4.6.0", + "core-js-compat": "^3.1.1", + "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", + "semver": "^5.5.0" + }, + "dependencies": { + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.6.3.tgz", + "integrity": "sha512-jTkk7/uE6H2s5w6VlMHeWuH+Pcy2lmdwFoeWCVnvIrDUnB5gQqTVI8WfmEAhF2CDEarGrknZcmSFg1+bkfCoSw==", + "dev": true, + "requires": { + "regexpu-core": "^4.6.0" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz", + "integrity": "sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.0" + } + }, + "regenerate-unicode-properties": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz", + "integrity": "sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-transform": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.1.tgz", + "integrity": "sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==", + "dev": true, + "requires": { + "private": "^0.1.6" + } + }, + "regexpu-core": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.6.0.tgz", + "integrity": "sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.1.0", + "regjsgen": "^0.5.0", + "regjsparser": "^0.6.0", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.1.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@babel/register": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.4.4.tgz", + "integrity": "sha512-sn51H88GRa00+ZoMqCVgOphmswG4b7mhf9VOB0LUBAieykq2GnRFerlN+JQkO/ntT7wz4jaHNSRPg9IdMPEUkA==", + "dev": true, + "requires": { + "core-js": "^3.0.0", + "find-cache-dir": "^2.0.0", + "lodash": "^4.17.11", + "mkdirp": "^0.5.1", + "pirates": "^4.0.0", + "source-map-support": "^0.5.9" + }, + "dependencies": { + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "@babel/runtime": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz", + "integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/runtime-corejs3": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.4.5.tgz", + "integrity": "sha512-yxHpUObdpx4zbuLJOHIZkw43E4NbWGsGH+85kRUj+VEcdtjlqI0vDcAja9mwOHqD7/4vCZp69q6eztz/sFDibQ==", + "dev": true, + "requires": { + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", + "integrity": "sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/traverse": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.4.5.tgz", + "integrity": "sha512-Vc+qjynwkjRmIFGxy0KYoPj4FdVDxLej89kMHFsWScq999uX+pwcX4v9mWRjW0KcAYTPAuVQl2LKP1wEVLsp+A==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.4.4", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.4.5", + "@babel/types": "^7.4.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.11" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.4.4.tgz", + "integrity": "sha512-dOllgYdnEFOebhkKCjzSVFqw/PmmB8pH6RGOWkY4GsboQNd47b1fBThBSwlHAq9alF9vc1M3+6oqR47R50L0tQ==", + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.11", + "to-fast-properties": "^2.0.0" + } + }, + "@cnakazawa/watch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.3.tgz", + "integrity": "sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA==", + "dev": true, + "requires": { + "exec-sh": "^0.3.2", + "minimist": "^1.2.0" + } + }, + "@evocateur/libnpmaccess": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz", + "integrity": "sha512-KSCAHwNWro0CF2ukxufCitT9K5LjL/KuMmNzSu8wuwN2rjyKHD8+cmOsiybK+W5hdnwc5M1SmRlVCaMHQo+3rg==", + "dev": true, + "requires": { + "@evocateur/npm-registry-fetch": "^4.0.0", + "aproba": "^2.0.0", + "figgy-pudding": "^3.5.1", + "get-stream": "^4.0.0", + "npm-package-arg": "^6.1.0" + }, + "dependencies": { + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, + "@evocateur/libnpmpublish": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@evocateur/libnpmpublish/-/libnpmpublish-1.2.2.tgz", + "integrity": "sha512-MJrrk9ct1FeY9zRlyeoyMieBjGDG9ihyyD9/Ft6MMrTxql9NyoEx2hw9casTIP4CdqEVu+3nQ2nXxoJ8RCXyFg==", + "dev": true, + "requires": { + "@evocateur/npm-registry-fetch": "^4.0.0", + "aproba": "^2.0.0", + "figgy-pudding": "^3.5.1", + "get-stream": "^4.0.0", + "lodash.clonedeep": "^4.5.0", + "normalize-package-data": "^2.4.0", + "npm-package-arg": "^6.1.0", + "semver": "^5.5.1", + "ssri": "^6.0.1" + }, + "dependencies": { + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + } + } + }, + "@evocateur/npm-registry-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@evocateur/npm-registry-fetch/-/npm-registry-fetch-4.0.0.tgz", + "integrity": "sha512-k1WGfKRQyhJpIr+P17O5vLIo2ko1PFLKwoetatdduUSt/aQ4J2sJrJwwatdI5Z3SiYk/mRH9S3JpdmMFd/IK4g==", + "dev": true, + "requires": { + "JSONStream": "^1.3.4", + "bluebird": "^3.5.1", + "figgy-pudding": "^3.4.1", + "lru-cache": "^5.1.1", + "make-fetch-happen": "^5.0.0", + "npm-package-arg": "^6.1.0", + "safe-buffer": "^5.1.2" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "@evocateur/pacote": { + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/@evocateur/pacote/-/pacote-9.6.3.tgz", + "integrity": "sha512-ExqNqcbdHQprEgKnY/uQz7WRtyHRbQxRl4JnVkSkmtF8qffRrF9K+piZKNLNSkRMOT/3H0e3IP44QVCHaXMWOQ==", + "dev": true, + "requires": { + "@evocateur/npm-registry-fetch": "^4.0.0", + "bluebird": "^3.5.3", + "cacache": "^12.0.0", + "figgy-pudding": "^3.5.1", + "get-stream": "^4.1.0", + "glob": "^7.1.4", + "lru-cache": "^5.1.1", + "make-fetch-happen": "^5.0.0", + "minimatch": "^3.0.4", + "minipass": "^2.3.5", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "normalize-package-data": "^2.5.0", + "npm-package-arg": "^6.1.0", + "npm-packlist": "^1.4.4", + "npm-pick-manifest": "^2.2.3", + "osenv": "^0.1.5", + "promise-inflight": "^1.0.1", + "promise-retry": "^1.1.1", + "protoduck": "^5.0.1", + "rimraf": "^2.6.3", + "safe-buffer": "^5.2.0", + "semver": "^5.7.0", + "ssri": "^6.0.1", + "tar": "^4.4.10", + "unique-filename": "^1.1.1", + "which": "^1.3.1" + }, + "dependencies": { + "bluebird": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.5.tgz", + "integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==", + "dev": true + }, + "cacache": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.2.tgz", + "integrity": "sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "chownr": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", + "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "mississippi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "tar": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz", + "integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.5", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "@hapi/address": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", + "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==", + "dev": true + }, + "@hapi/hoek": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", + "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==", + "dev": true + }, + "@hapi/joi": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", + "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", + "dev": true, + "requires": { + "@hapi/address": "2.x.x", + "@hapi/hoek": "6.x.x", + "@hapi/marker": "1.x.x", + "@hapi/topo": "3.x.x" + } + }, + "@hapi/marker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", + "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==", + "dev": true + }, + "@hapi/topo": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.2.tgz", + "integrity": "sha512-r+aumOqJ5QbD6aLPJWqVjMAPsx5pZKz+F5yPqXZ/WWG9JTtHbQqlzrJoknJ0iJxLj9vlXtmpSdjlkszseeG8OA==", + "dev": true, + "requires": { + "@hapi/hoek": "8.x.x" + }, + "dependencies": { + "@hapi/hoek": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.0.2.tgz", + "integrity": "sha512-O6o6mrV4P65vVccxymuruucb+GhP2zl9NLCG8OdoFRS8BEGw3vwpPp20wpAtpbQQxz1CEUtmxJGgWhjq1XA3qw==", + "dev": true + } + } + }, + "@iarna/toml": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.3.tgz", + "integrity": "sha512-FmuxfCuolpLl0AnQ2NHSzoUKWEJDFl63qXjzdoWBVyFCXzMGm1spBzk7LeHNoVCiWCF7mRVms9e6jEV9+MoPbg==", + "dev": true + }, + "@jest/console": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.7.1.tgz", + "integrity": "sha512-iNhtIy2M8bXlAOULWVTUxmnelTLFneTNEkHCgPmgd+zNwy9zVddJ6oS5rZ9iwoscNdT5mMwUd0C51v/fSlzItg==", + "dev": true, + "requires": { + "@jest/source-map": "^24.3.0", + "chalk": "^2.0.1", + "slash": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@jest/core": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-24.7.1.tgz", + "integrity": "sha512-ivlZ8HX/FOASfHcb5DJpSPFps8ydfUYzLZfgFFqjkLijYysnIEOieg72YRhO4ZUB32xu40hsSMmaw+IGYeKONA==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/reporters": "^24.7.1", + "@jest/test-result": "^24.7.1", + "@jest/transform": "^24.7.1", + "@jest/types": "^24.7.0", + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "graceful-fs": "^4.1.15", + "jest-changed-files": "^24.7.0", + "jest-config": "^24.7.1", + "jest-haste-map": "^24.7.1", + "jest-message-util": "^24.7.1", + "jest-regex-util": "^24.3.0", + "jest-resolve-dependencies": "^24.7.1", + "jest-runner": "^24.7.1", + "jest-runtime": "^24.7.1", + "jest-snapshot": "^24.7.1", + "jest-util": "^24.7.1", + "jest-validate": "^24.7.0", + "jest-watcher": "^24.7.1", + "micromatch": "^3.1.10", + "p-each-series": "^1.0.0", + "pirates": "^4.0.1", + "realpath-native": "^1.1.0", + "rimraf": "^2.5.4", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "@jest/environment": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-24.7.1.tgz", + "integrity": "sha512-wmcTTYc4/KqA+U5h1zQd5FXXynfa7VGP2NfF+c6QeGJ7c+2nStgh65RQWNX62SC716dTtqheTRrZl0j+54oGHw==", + "dev": true, + "requires": { + "@jest/fake-timers": "^24.7.1", + "@jest/transform": "^24.7.1", + "@jest/types": "^24.7.0", + "jest-mock": "^24.7.0" + } + }, + "@jest/fake-timers": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-24.7.1.tgz", + "integrity": "sha512-4vSQJDKfR2jScOe12L9282uiwuwQv9Lk7mgrCSZHA9evB9efB/qx8i0KJxsAKtp8fgJYBJdYY7ZU6u3F4/pyjA==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "jest-message-util": "^24.7.1", + "jest-mock": "^24.7.0" + } + }, + "@jest/reporters": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-24.7.1.tgz", + "integrity": "sha512-bO+WYNwHLNhrjB9EbPL4kX/mCCG4ZhhfWmO3m4FSpbgr7N83MFejayz30kKjgqr7smLyeaRFCBQMbXpUgnhAJw==", + "dev": true, + "requires": { + "@jest/environment": "^24.7.1", + "@jest/test-result": "^24.7.1", + "@jest/transform": "^24.7.1", + "@jest/types": "^24.7.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "glob": "^7.1.2", + "istanbul-api": "^2.1.1", + "istanbul-lib-coverage": "^2.0.2", + "istanbul-lib-instrument": "^3.0.1", + "istanbul-lib-source-maps": "^3.0.1", + "jest-haste-map": "^24.7.1", + "jest-resolve": "^24.7.1", + "jest-runtime": "^24.7.1", + "jest-util": "^24.7.1", + "jest-worker": "^24.6.0", + "node-notifier": "^5.2.1", + "slash": "^2.0.0", + "source-map": "^0.6.0", + "string-length": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/source-map": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-24.3.0.tgz", + "integrity": "sha512-zALZt1t2ou8le/crCeeiRYzvdnTzaIlpOWaet45lNSqNJUnXbppUUFR4ZUAlzgDmKee4Q5P/tKXypI1RiHwgag==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.1.15", + "source-map": "^0.6.0" + }, + "dependencies": { + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/test-result": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-24.7.1.tgz", + "integrity": "sha512-3U7wITxstdEc2HMfBX7Yx3JZgiNBubwDqQMh+BXmZXHa3G13YWF3p6cK+5g0hGkN3iufg/vGPl3hLxQXD74Npg==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/types": "^24.7.0", + "@types/istanbul-lib-coverage": "^2.0.0" + } + }, + "@jest/test-sequencer": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-24.7.1.tgz", + "integrity": "sha512-84HQkCpVZI/G1zq53gHJvSmhUer4aMYp9tTaffW28Ih5OxfCg8hGr3nTSbL1OhVDRrFZwvF+/R9gY6JRkDUpUA==", + "dev": true, + "requires": { + "@jest/test-result": "^24.7.1", + "jest-haste-map": "^24.7.1", + "jest-runner": "^24.7.1", + "jest-runtime": "^24.7.1" + } + }, + "@jest/transform": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-24.7.1.tgz", + "integrity": "sha512-EsOUqP9ULuJ66IkZQhI5LufCHlTbi7hrcllRMUEV/tOgqBVQi93+9qEvkX0n8mYpVXQ8VjwmICeRgg58mrtIEw==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^24.7.0", + "babel-plugin-istanbul": "^5.1.0", + "chalk": "^2.0.1", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.1.15", + "jest-haste-map": "^24.7.1", + "jest-regex-util": "^24.3.0", + "jest-util": "^24.7.1", + "micromatch": "^3.1.10", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "2.4.1" + }, + "dependencies": { + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "write-file-atomic": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.1.tgz", + "integrity": "sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + } + } + }, + "@jest/types": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.7.0.tgz", + "integrity": "sha512-ipJUa2rFWiKoBqMKP63Myb6h9+iT3FHRTF2M8OR6irxWzItisa8i4dcSg14IbvmXUnBlHBlUQPYUHWyX3UPpYA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/yargs": "^12.0.9" + } + }, + "@lerna/add": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/add/-/add-3.16.2.tgz", + "integrity": "sha512-RAAaF8aODPogj2Ge9Wj3uxPFIBGpog9M+HwSuq03ZnkkO831AmasCTJDqV+GEpl1U2DvnhZQEwHpWmTT0uUeEw==", + "dev": true, + "requires": { + "@evocateur/pacote": "^9.6.3", + "@lerna/bootstrap": "3.16.2", + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/npm-conf": "3.16.0", + "@lerna/validation-error": "3.13.0", + "dedent": "^0.7.0", + "npm-package-arg": "^6.1.0", + "p-map": "^2.1.0", + "semver": "^6.2.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/batch-packages": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/batch-packages/-/batch-packages-3.16.0.tgz", + "integrity": "sha512-7AdMkANpubY/FKFI01im01tlx6ygOBJ/0JcixMUWoWP/7Ds3SWQF22ID6fbBr38jUWptYLDs2fagtTDL7YUPuA==", + "dev": true, + "requires": { + "@lerna/package-graph": "3.16.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/bootstrap": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.16.2.tgz", + "integrity": "sha512-I+gs7eh6rv9Vyd+CwqL7sftRfOOsSzCle8cv/CGlMN7/p7EAVhxEdAw8SYoHIKHzipXszuqqy1Y3opyleD0qdA==", + "dev": true, + "requires": { + "@lerna/batch-packages": "3.16.0", + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/has-npm-version": "3.16.0", + "@lerna/npm-install": "3.16.0", + "@lerna/package-graph": "3.16.0", + "@lerna/pulse-till-done": "3.13.0", + "@lerna/rimraf-dir": "3.14.2", + "@lerna/run-lifecycle": "3.16.2", + "@lerna/run-parallel-batches": "3.16.0", + "@lerna/symlink-binary": "3.16.2", + "@lerna/symlink-dependencies": "3.16.2", + "@lerna/validation-error": "3.13.0", + "dedent": "^0.7.0", + "get-port": "^4.2.0", + "multimatch": "^3.0.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "p-finally": "^1.0.0", + "p-map": "^2.1.0", + "p-map-series": "^1.0.0", + "p-waterfall": "^1.0.0", + "read-package-tree": "^5.1.6", + "semver": "^6.2.0" + }, + "dependencies": { + "get-port": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-4.2.0.tgz", + "integrity": "sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw==", + "dev": true + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/changed": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/@lerna/changed/-/changed-3.16.4.tgz", + "integrity": "sha512-NCD7XkK744T23iW0wqKEgF4R9MYmReUbyHCZKopFnsNpQdqumc3SOIvQUAkKCP6hQJmYvxvOieoVgy/CVDpZ5g==", + "dev": true, + "requires": { + "@lerna/collect-updates": "3.16.0", + "@lerna/command": "3.16.0", + "@lerna/listable": "3.16.0", + "@lerna/output": "3.13.0", + "@lerna/version": "3.16.4" + } + }, + "@lerna/check-working-tree": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/check-working-tree/-/check-working-tree-3.14.2.tgz", + "integrity": "sha512-7safqxM/MYoAoxZxulUDtIJIbnBIgo0PB/FHytueG+9VaX7GMnDte2Bt1EKa0dz2sAyQdmQ3Q8ZXpf/6JDjaeg==", + "dev": true, + "requires": { + "@lerna/collect-uncommitted": "3.14.2", + "@lerna/describe-ref": "3.14.2", + "@lerna/validation-error": "3.13.0" + } + }, + "@lerna/child-process": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/child-process/-/child-process-3.14.2.tgz", + "integrity": "sha512-xnq+W5yQb6RkwI0p16ZQnrn6HkloH/MWTw4lGE1nKsBLAUbmSU5oTE93W1nrG0X3IMF/xWc9UYvNdUGMWvZZ4w==", + "dev": true, + "requires": { + "chalk": "^2.3.1", + "execa": "^1.0.0", + "strong-log-transformer": "^2.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "@lerna/clean": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/clean/-/clean-3.16.0.tgz", + "integrity": "sha512-5P9U5Y19WmYZr7UAMGXBpY7xCRdlR7zhHy8MAPDKVx70rFIBS6nWXn5n7Kntv74g7Lm1gJ2rsiH5tj1OPcRJgg==", + "dev": true, + "requires": { + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/prompt": "3.13.0", + "@lerna/pulse-till-done": "3.13.0", + "@lerna/rimraf-dir": "3.14.2", + "p-map": "^2.1.0", + "p-map-series": "^1.0.0", + "p-waterfall": "^1.0.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/cli": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/cli/-/cli-3.13.0.tgz", + "integrity": "sha512-HgFGlyCZbYaYrjOr3w/EsY18PdvtsTmDfpUQe8HwDjXlPeCCUgliZjXLOVBxSjiOvPeOSwvopwIHKWQmYbwywg==", + "dev": true, + "requires": { + "@lerna/global-options": "3.13.0", + "dedent": "^0.7.0", + "npmlog": "^4.1.2", + "yargs": "^12.0.1" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "@lerna/collect-uncommitted": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/collect-uncommitted/-/collect-uncommitted-3.14.2.tgz", + "integrity": "sha512-4EkQu4jIOdNL2BMzy/N0ydHB8+Z6syu6xiiKXOoFl0WoWU9H1jEJCX4TH7CmVxXL1+jcs8FIS2pfQz4oew99Eg==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "chalk": "^2.3.1", + "figgy-pudding": "^3.5.1", + "npmlog": "^4.1.2" + } + }, + "@lerna/collect-updates": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/collect-updates/-/collect-updates-3.16.0.tgz", + "integrity": "sha512-HwAIl815X2TNlmcp28zCrSdXfoZWNP7GJPEqNWYk7xDJTYLqQ+SrmKUePjb3AMGBwYAraZSEJLbHdBpJ5+cHmQ==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/describe-ref": "3.14.2", + "minimatch": "^3.0.4", + "npmlog": "^4.1.2", + "slash": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@lerna/command": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/command/-/command-3.16.0.tgz", + "integrity": "sha512-u7tE4GC4/gfbPA9eQg+0ulnoJ+PMoMqomx033r/IxqZrHtmJR9+pF/37S0fsxJ2hX/RMFPC7c9Q/i8NEufSpdQ==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/package-graph": "3.16.0", + "@lerna/project": "3.16.0", + "@lerna/validation-error": "3.13.0", + "@lerna/write-log-file": "3.13.0", + "dedent": "^0.7.0", + "execa": "^1.0.0", + "is-ci": "^2.0.0", + "lodash": "^4.17.14", + "npmlog": "^4.1.2" + }, + "dependencies": { + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "@lerna/conventional-commits": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/@lerna/conventional-commits/-/conventional-commits-3.16.4.tgz", + "integrity": "sha512-QSZJ0bC9n6FVaf+7KDIq5zMv8WnHXnwhyL5jG1Nyh3SgOg9q2uflqh7YsYB+G6FwaRfnPaKosh6obijpYg0llA==", + "dev": true, + "requires": { + "@lerna/validation-error": "3.13.0", + "conventional-changelog-angular": "^5.0.3", + "conventional-changelog-core": "^3.1.6", + "conventional-recommended-bump": "^5.0.0", + "fs-extra": "^8.1.0", + "get-stream": "^4.0.0", + "lodash.template": "^4.5.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "pify": "^4.0.1", + "semver": "^6.2.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/create": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/create/-/create-3.16.0.tgz", + "integrity": "sha512-OZApR1Iz7awutbmj4sAArwhqCyKgcrnw9rH0aWAUrkYWrD1w4TwkvAcYAsfx5GpQGbLQwoXhoyyPwPfZRRWz3Q==", + "dev": true, + "requires": { + "@evocateur/pacote": "^9.6.3", + "@lerna/child-process": "3.14.2", + "@lerna/command": "3.16.0", + "@lerna/npm-conf": "3.16.0", + "@lerna/validation-error": "3.13.0", + "camelcase": "^5.0.0", + "dedent": "^0.7.0", + "fs-extra": "^8.1.0", + "globby": "^9.2.0", + "init-package-json": "^1.10.3", + "npm-package-arg": "^6.1.0", + "p-reduce": "^1.0.0", + "pify": "^4.0.1", + "semver": "^6.2.0", + "slash": "^2.0.0", + "validate-npm-package-license": "^3.0.3", + "validate-npm-package-name": "^3.0.0", + "whatwg-url": "^7.0.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "whatwg-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.0.0.tgz", + "integrity": "sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + } + } + }, + "@lerna/create-symlink": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/create-symlink/-/create-symlink-3.16.2.tgz", + "integrity": "sha512-pzXIJp6av15P325sgiIRpsPXLFmkisLhMBCy4764d+7yjf2bzrJ4gkWVMhsv4AdF0NN3OyZ5jjzzTtLNqfR+Jw==", + "dev": true, + "requires": { + "@zkochan/cmd-shim": "^3.1.0", + "fs-extra": "^8.1.0", + "npmlog": "^4.1.2" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + } + } + }, + "@lerna/describe-ref": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/describe-ref/-/describe-ref-3.14.2.tgz", + "integrity": "sha512-qa5pzDRK2oBQXNjyRmRnN7E8a78NMYfQjjlRFB0KNHMsT6mCiL9+8kIS39sSE2NqT8p7xVNo2r2KAS8R/m3CoQ==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "npmlog": "^4.1.2" + } + }, + "@lerna/diff": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/diff/-/diff-3.16.0.tgz", + "integrity": "sha512-QUpVs5TPl8vBIne10/vyjUxanQBQQp7Lk3iaB8MnCysKr0O+oy7trWeFVDPEkBTCD177By7yPGyW5Yey1nCBbA==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/command": "3.16.0", + "@lerna/validation-error": "3.13.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/exec": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/exec/-/exec-3.16.0.tgz", + "integrity": "sha512-mH3O5NXf/O88jBaBBTUf+d56CUkxpg782s3Jxy7HWbVuSUULt3iMRPTh+zEXO5/555etsIVVDDyUR76meklrJA==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/run-topologically": "3.16.0", + "@lerna/validation-error": "3.13.0", + "p-map": "^2.1.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/filter-options": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/filter-options/-/filter-options-3.16.0.tgz", + "integrity": "sha512-InIi1fF8+PxpCwir9bIy+pGxrdE6hvN0enIs1eNGCVS1TTE8osNgiZXa838bMQ1yaEccdcnVX6Z03BNKd56kNg==", + "dev": true, + "requires": { + "@lerna/collect-updates": "3.16.0", + "@lerna/filter-packages": "3.16.0", + "dedent": "^0.7.0" + } + }, + "@lerna/filter-packages": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/filter-packages/-/filter-packages-3.16.0.tgz", + "integrity": "sha512-eGFzQTx0ogkGDCnbTuXqssryR6ilp8+dcXt6B+aq1MaqL/vOJRZyqMm4TY3CUOUnzZCi9S2WWyMw3PnAJOF+kg==", + "dev": true, + "requires": { + "@lerna/validation-error": "3.13.0", + "multimatch": "^3.0.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/get-npm-exec-opts": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/get-npm-exec-opts/-/get-npm-exec-opts-3.13.0.tgz", + "integrity": "sha512-Y0xWL0rg3boVyJk6An/vurKzubyJKtrxYv2sj4bB8Mc5zZ3tqtv0ccbOkmkXKqbzvNNF7VeUt1OJ3DRgtC/QZw==", + "dev": true, + "requires": { + "npmlog": "^4.1.2" + } + }, + "@lerna/get-packed": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/get-packed/-/get-packed-3.16.0.tgz", + "integrity": "sha512-AjsFiaJzo1GCPnJUJZiTW6J1EihrPkc2y3nMu6m3uWFxoleklsSCyImumzVZJssxMi3CPpztj8LmADLedl9kXw==", + "dev": true, + "requires": { + "fs-extra": "^8.1.0", + "ssri": "^6.0.1", + "tar": "^4.4.8" + }, + "dependencies": { + "chownr": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", + "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "tar": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz", + "integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.5", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "@lerna/github-client": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.16.0.tgz", + "integrity": "sha512-IVJjcKjkYaUEPJsDyAblHGEFFNKCRyMagbIDm14L7Ab94ccN6i4TKOqAFEJn2SJHYvKKBdp3Zj2zNlASOMe3DA==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@octokit/plugin-enterprise-rest": "^3.6.1", + "@octokit/rest": "^16.28.4", + "git-url-parse": "^11.1.2", + "npmlog": "^4.1.2" + }, + "dependencies": { + "@octokit/request": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.0.2.tgz", + "integrity": "sha512-z1BQr43g4kOL4ZrIVBMHwi68Yg9VbkRUyuAgqCp1rU3vbYa69+2gIld/+gHclw15bJWQnhqqyEb7h5a5EqgZ0A==", + "dev": true, + "requires": { + "@octokit/endpoint": "^5.1.0", + "@octokit/request-error": "^1.0.1", + "deprecation": "^2.0.0", + "is-plain-object": "^3.0.0", + "node-fetch": "^2.3.0", + "once": "^1.4.0", + "universal-user-agent": "^3.0.0" + } + }, + "@octokit/rest": { + "version": "16.28.7", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.28.7.tgz", + "integrity": "sha512-cznFSLEhh22XD3XeqJw51OLSfyL2fcFKUO+v2Ep9MTAFfFLS1cK1Zwd1yEgQJmJoDnj4/vv3+fGGZweG+xsbIA==", + "dev": true, + "requires": { + "@octokit/request": "^5.0.0", + "@octokit/request-error": "^1.0.2", + "atob-lite": "^2.0.0", + "before-after-hook": "^2.0.0", + "btoa-lite": "^1.0.0", + "deprecation": "^2.0.0", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.uniq": "^4.5.0", + "octokit-pagination-methods": "^1.1.0", + "once": "^1.4.0", + "universal-user-agent": "^3.0.0", + "url-template": "^2.0.8" + } + }, + "before-after-hook": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz", + "integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==", + "dev": true + }, + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + }, + "universal-user-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-3.0.0.tgz", + "integrity": "sha512-T3siHThqoj5X0benA5H0qcDnrKGXzU8TKoX15x/tQHw1hQBvIEBHjxQ2klizYsqBOO/Q+WuxoQUihadeeqDnoA==", + "dev": true, + "requires": { + "os-name": "^3.0.0" + } + } + } + }, + "@lerna/gitlab-client": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@lerna/gitlab-client/-/gitlab-client-3.15.0.tgz", + "integrity": "sha512-OsBvRSejHXUBMgwWQqNoioB8sgzL/Pf1pOUhHKtkiMl6aAWjklaaq5HPMvTIsZPfS6DJ9L5OK2GGZuooP/5c8Q==", + "dev": true, + "requires": { + "node-fetch": "^2.5.0", + "npmlog": "^4.1.2", + "whatwg-url": "^7.0.0" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + }, + "whatwg-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.0.0.tgz", + "integrity": "sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + } + } + }, + "@lerna/global-options": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/global-options/-/global-options-3.13.0.tgz", + "integrity": "sha512-SlZvh1gVRRzYLVluz9fryY1nJpZ0FHDGB66U9tFfvnnxmueckRQxLopn3tXj3NU1kc3QANT2I5BsQkOqZ4TEFQ==", + "dev": true + }, + "@lerna/has-npm-version": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/has-npm-version/-/has-npm-version-3.16.0.tgz", + "integrity": "sha512-TIY036dA9J8OyTrZq9J+it2DVKifL65k7hK8HhkUPpitJkw6jwbMObA/8D40LOGgWNPweJWqmlrTbRSwsR7DrQ==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/import": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/import/-/import-3.16.0.tgz", + "integrity": "sha512-trsOmGHzw0rL/f8BLNvd+9PjoTkXq2Dt4/V2UCha254hMQaYutbxcYu8iKPxz9x86jSPlH7FpbTkkHXDsoY7Yg==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/command": "3.16.0", + "@lerna/prompt": "3.13.0", + "@lerna/pulse-till-done": "3.13.0", + "@lerna/validation-error": "3.13.0", + "dedent": "^0.7.0", + "fs-extra": "^8.1.0", + "p-map-series": "^1.0.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + } + } + }, + "@lerna/init": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/init/-/init-3.16.0.tgz", + "integrity": "sha512-Ybol/x5xMtBgokx4j7/Y3u0ZmNh0NiSWzBFVaOs2NOJKvuqrWimF67DKVz7yYtTYEjtaMdug64ohFF4jcT/iag==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/command": "3.16.0", + "fs-extra": "^8.1.0", + "p-map": "^2.1.0", + "write-json-file": "^3.2.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/link": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/link/-/link-3.16.2.tgz", + "integrity": "sha512-eCPg5Lo8HT525fIivNoYF3vWghO3UgEVFdbsiPmhzwI7IQyZro5HWYzLtywSAdEog5XZpd2Bbn0CsoHWBB3gww==", + "dev": true, + "requires": { + "@lerna/command": "3.16.0", + "@lerna/package-graph": "3.16.0", + "@lerna/symlink-dependencies": "3.16.2", + "p-map": "^2.1.0", + "slash": "^2.0.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@lerna/list": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/list/-/list-3.16.0.tgz", + "integrity": "sha512-TkvstoPsgKqqQ0KfRumpsdMXfRSEhdXqOLq519XyI5IRWYxhoqXqfi8gG37UoBPhBNoe64japn5OjphF3rOmQA==", + "dev": true, + "requires": { + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/listable": "3.16.0", + "@lerna/output": "3.13.0" + } + }, + "@lerna/listable": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/listable/-/listable-3.16.0.tgz", + "integrity": "sha512-mtdAT2EEECqrJSDm/aXlOUFr1MRE4p6hppzY//Klp05CogQy6uGaKk+iKG5yyCLaOXFFZvG4HfO11CmoGSDWzw==", + "dev": true, + "requires": { + "@lerna/query-graph": "3.16.0", + "chalk": "^2.3.1", + "columnify": "^1.5.4" + } + }, + "@lerna/log-packed": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/log-packed/-/log-packed-3.16.0.tgz", + "integrity": "sha512-Fp+McSNBV/P2mnLUYTaSlG8GSmpXM7krKWcllqElGxvAqv6chk2K3c2k80MeVB4WvJ9tRjUUf+i7HUTiQ9/ckQ==", + "dev": true, + "requires": { + "byte-size": "^5.0.1", + "columnify": "^1.5.4", + "has-unicode": "^2.0.1", + "npmlog": "^4.1.2" + } + }, + "@lerna/npm-conf": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/npm-conf/-/npm-conf-3.16.0.tgz", + "integrity": "sha512-HbO3DUrTkCAn2iQ9+FF/eisDpWY5POQAOF1m7q//CZjdC2HSW3UYbKEGsSisFxSfaF9Z4jtrV+F/wX6qWs3CuA==", + "dev": true, + "requires": { + "config-chain": "^1.1.11", + "pify": "^4.0.1" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, + "@lerna/npm-dist-tag": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/npm-dist-tag/-/npm-dist-tag-3.16.0.tgz", + "integrity": "sha512-MQrBkqJJB9+eNphuj9w90QPMOs4NQXMuSRk9NqzeFunOmdDopPCV0Q7IThSxEuWnhJ2n3B7G0vWUP7tNMPdqIQ==", + "dev": true, + "requires": { + "@evocateur/npm-registry-fetch": "^4.0.0", + "@lerna/otplease": "3.16.0", + "figgy-pudding": "^3.5.1", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/npm-install": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/npm-install/-/npm-install-3.16.0.tgz", + "integrity": "sha512-APUOIilZCzDzce92uLEwzt1r7AEMKT/hWA1ThGJL+PO9Rn8A95Km3o2XZAYG4W0hR+P4O2nSVuKbsjQtz8CjFQ==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/get-npm-exec-opts": "3.13.0", + "fs-extra": "^8.1.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "signal-exit": "^3.0.2", + "write-pkg": "^3.1.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + } + } + }, + "@lerna/npm-publish": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/npm-publish/-/npm-publish-3.16.2.tgz", + "integrity": "sha512-tGMb9vfTxP57vUV5svkBQxd5Tzc+imZbu9ZYf8Mtwe0+HYfDjNiiHLIQw7G95w4YRdc5KsCE8sQ0uSj+f2soIg==", + "dev": true, + "requires": { + "@evocateur/libnpmpublish": "^1.2.2", + "@lerna/otplease": "3.16.0", + "@lerna/run-lifecycle": "3.16.2", + "figgy-pudding": "^3.5.1", + "fs-extra": "^8.1.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "pify": "^4.0.1", + "read-package-json": "^2.0.13" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, + "@lerna/npm-run-script": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/npm-run-script/-/npm-run-script-3.14.2.tgz", + "integrity": "sha512-LbVFv+nvAoRTYLMrJlJ8RiakHXrLslL7Jp/m1R18vYrB8LYWA3ey+nz5Tel2OELzmjUiemAKZsD9h6i+Re5egg==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "@lerna/get-npm-exec-opts": "3.13.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/otplease": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/otplease/-/otplease-3.16.0.tgz", + "integrity": "sha512-uqZ15wYOHC+/V0WnD2iTLXARjvx3vNrpiIeyIvVlDB7rWse9mL4egex/QSgZ+lDx1OID7l2kgvcUD9cFpbqB7Q==", + "dev": true, + "requires": { + "@lerna/prompt": "3.13.0", + "figgy-pudding": "^3.5.1" + } + }, + "@lerna/output": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/output/-/output-3.13.0.tgz", + "integrity": "sha512-7ZnQ9nvUDu/WD+bNsypmPG5MwZBwu86iRoiW6C1WBuXXDxM5cnIAC1m2WxHeFnjyMrYlRXM9PzOQ9VDD+C15Rg==", + "dev": true, + "requires": { + "npmlog": "^4.1.2" + } + }, + "@lerna/pack-directory": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/@lerna/pack-directory/-/pack-directory-3.16.4.tgz", + "integrity": "sha512-uxSF0HZeGyKaaVHz5FroDY9A5NDDiCibrbYR6+khmrhZtY0Bgn6hWq8Gswl9iIlymA+VzCbshWIMX4o2O8C8ng==", + "dev": true, + "requires": { + "@lerna/get-packed": "3.16.0", + "@lerna/package": "3.16.0", + "@lerna/run-lifecycle": "3.16.2", + "figgy-pudding": "^3.5.1", + "npm-packlist": "^1.4.4", + "npmlog": "^4.1.2", + "tar": "^4.4.10", + "temp-write": "^3.4.0" + }, + "dependencies": { + "chownr": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", + "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "dev": true + }, + "tar": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz", + "integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.5", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "@lerna/package": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/package/-/package-3.16.0.tgz", + "integrity": "sha512-2lHBWpaxcBoiNVbtyLtPUuTYEaB/Z+eEqRS9duxpZs6D+mTTZMNy6/5vpEVSCBmzvdYpyqhqaYjjSLvjjr5Riw==", + "dev": true, + "requires": { + "load-json-file": "^5.3.0", + "npm-package-arg": "^6.1.0", + "write-pkg": "^3.1.0" + }, + "dependencies": { + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "@lerna/package-graph": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/package-graph/-/package-graph-3.16.0.tgz", + "integrity": "sha512-A2mum/gNbv7zCtAwJqoxzqv89As73OQNK2MgSX1SHWya46qoxO9a9Z2c5lOFQ8UFN5ZxqWMfFYXRCz7qzwmFXw==", + "dev": true, + "requires": { + "@lerna/prerelease-id-from-version": "3.16.0", + "@lerna/validation-error": "3.13.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/prerelease-id-from-version": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/prerelease-id-from-version/-/prerelease-id-from-version-3.16.0.tgz", + "integrity": "sha512-qZyeUyrE59uOK8rKdGn7jQz+9uOpAaF/3hbslJVFL1NqF9ELDTqjCPXivuejMX/lN4OgD6BugTO4cR7UTq/sZA==", + "dev": true, + "requires": { + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/project": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/project/-/project-3.16.0.tgz", + "integrity": "sha512-NrKcKK1EqXqhrGvslz6Q36+ZHuK3zlDhGdghRqnxDcHxMPT01NgLcmsnymmQ+gjMljuLRmvKYYCuHrknzX8VrA==", + "dev": true, + "requires": { + "@lerna/package": "3.16.0", + "@lerna/validation-error": "3.13.0", + "cosmiconfig": "^5.1.0", + "dedent": "^0.7.0", + "dot-prop": "^4.2.0", + "glob-parent": "^5.0.0", + "globby": "^9.2.0", + "load-json-file": "^5.3.0", + "npmlog": "^4.1.2", + "p-map": "^2.1.0", + "resolve-from": "^4.0.0", + "write-json-file": "^3.2.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "glob-parent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", + "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + } + } + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + } + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "@lerna/prompt": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/prompt/-/prompt-3.13.0.tgz", + "integrity": "sha512-P+lWSFokdyvYpkwC3it9cE0IF2U5yy2mOUbGvvE4iDb9K7TyXGE+7lwtx2thtPvBAfIb7O13POMkv7df03HJeA==", + "dev": true, + "requires": { + "inquirer": "^6.2.0", + "npmlog": "^4.1.2" + } + }, + "@lerna/publish": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/@lerna/publish/-/publish-3.16.4.tgz", + "integrity": "sha512-XZY+gRuF7/v6PDQwl7lvZaGWs8CnX6WIPIu+OCcyFPSL/rdWegdN7HieKBHskgX798qRQc2GrveaY7bNoTKXAw==", + "dev": true, + "requires": { + "@evocateur/libnpmaccess": "^3.1.2", + "@evocateur/npm-registry-fetch": "^4.0.0", + "@evocateur/pacote": "^9.6.3", + "@lerna/check-working-tree": "3.14.2", + "@lerna/child-process": "3.14.2", + "@lerna/collect-updates": "3.16.0", + "@lerna/command": "3.16.0", + "@lerna/describe-ref": "3.14.2", + "@lerna/log-packed": "3.16.0", + "@lerna/npm-conf": "3.16.0", + "@lerna/npm-dist-tag": "3.16.0", + "@lerna/npm-publish": "3.16.2", + "@lerna/otplease": "3.16.0", + "@lerna/output": "3.13.0", + "@lerna/pack-directory": "3.16.4", + "@lerna/prerelease-id-from-version": "3.16.0", + "@lerna/prompt": "3.13.0", + "@lerna/pulse-till-done": "3.13.0", + "@lerna/run-lifecycle": "3.16.2", + "@lerna/run-topologically": "3.16.0", + "@lerna/validation-error": "3.13.0", + "@lerna/version": "3.16.4", + "figgy-pudding": "^3.5.1", + "fs-extra": "^8.1.0", + "npm-package-arg": "^6.1.0", + "npmlog": "^4.1.2", + "p-finally": "^1.0.0", + "p-map": "^2.1.0", + "p-pipe": "^1.2.0", + "semver": "^6.2.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@lerna/pulse-till-done": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/pulse-till-done/-/pulse-till-done-3.13.0.tgz", + "integrity": "sha512-1SOHpy7ZNTPulzIbargrgaJX387csN7cF1cLOGZiJQA6VqnS5eWs2CIrG8i8wmaUavj2QlQ5oEbRMVVXSsGrzA==", + "dev": true, + "requires": { + "npmlog": "^4.1.2" + } + }, + "@lerna/query-graph": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/query-graph/-/query-graph-3.16.0.tgz", + "integrity": "sha512-p0RO+xmHDO95ChJdWkcy9TNLysLkoDARXeRHzY5U54VCwl3Ot/2q8fMCVlA5UeGXDutEyyByl3URqEpcQCWI7Q==", + "dev": true, + "requires": { + "@lerna/package-graph": "3.16.0", + "figgy-pudding": "^3.5.1" + } + }, + "@lerna/resolve-symlink": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/resolve-symlink/-/resolve-symlink-3.16.0.tgz", + "integrity": "sha512-Ibj5e7njVHNJ/NOqT4HlEgPFPtPLWsO7iu59AM5bJDcAJcR96mLZ7KGVIsS2tvaO7akMEJvt2P+ErwCdloG3jQ==", + "dev": true, + "requires": { + "fs-extra": "^8.1.0", + "npmlog": "^4.1.2", + "read-cmd-shim": "^1.0.1" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + } + } + }, + "@lerna/rimraf-dir": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@lerna/rimraf-dir/-/rimraf-dir-3.14.2.tgz", + "integrity": "sha512-eFNkZsy44Bu9v1Hrj5Zk6omzg8O9h/7W6QYK1TTUHeyrjTEwytaNQlqF0lrTLmEvq55sviV42NC/8P3M2cvq8Q==", + "dev": true, + "requires": { + "@lerna/child-process": "3.14.2", + "npmlog": "^4.1.2", + "path-exists": "^3.0.0", + "rimraf": "^2.6.2" + } + }, + "@lerna/run": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/run/-/run-3.16.0.tgz", + "integrity": "sha512-woTeLlB1OAAz4zzjdI6RyIxSGuxiUPHJZm89E1pDEPoWwtQV6HMdMgrsQd9ATsJ5Ez280HH4bF/LStAlqW8Ufg==", + "dev": true, + "requires": { + "@lerna/command": "3.16.0", + "@lerna/filter-options": "3.16.0", + "@lerna/npm-run-script": "3.14.2", + "@lerna/output": "3.13.0", + "@lerna/run-topologically": "3.16.0", + "@lerna/timer": "3.13.0", + "@lerna/validation-error": "3.13.0", + "p-map": "^2.1.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/run-lifecycle": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/run-lifecycle/-/run-lifecycle-3.16.2.tgz", + "integrity": "sha512-RqFoznE8rDpyyF0rOJy3+KjZCeTkO8y/OB9orPauR7G2xQ7PTdCpgo7EO6ZNdz3Al+k1BydClZz/j78gNCmL2A==", + "dev": true, + "requires": { + "@lerna/npm-conf": "3.16.0", + "figgy-pudding": "^3.5.1", + "npm-lifecycle": "^3.1.2", + "npmlog": "^4.1.2" + } + }, + "@lerna/run-parallel-batches": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/run-parallel-batches/-/run-parallel-batches-3.16.0.tgz", + "integrity": "sha512-2J/Nyv+MvogmQEfC7VcS21ifk7w0HVvzo2yOZRPvkCzGRu/rducxtB4RTcr58XCZ8h/Bt1aqQYKExu3c/3GXwg==", + "dev": true, + "requires": { + "p-map": "^2.1.0", + "p-map-series": "^1.0.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/run-topologically": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@lerna/run-topologically/-/run-topologically-3.16.0.tgz", + "integrity": "sha512-4Hlpv4zDtKWa5Z0tPkeu0sK+bxZEKgkNESMGmWrUCNfj7xwvAJurcraK8+a2Y0TFYwf0qjSLY/MzX+ZbJA3Cgw==", + "dev": true, + "requires": { + "@lerna/query-graph": "3.16.0", + "figgy-pudding": "^3.5.1", + "p-queue": "^4.0.0" + } + }, + "@lerna/symlink-binary": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/symlink-binary/-/symlink-binary-3.16.2.tgz", + "integrity": "sha512-kz9XVoFOGSF83gg4gBqH+mG6uxfJfTp8Uy+Cam40CvMiuzfODrGkjuBEFoM/uO2QOAwZvbQDYOBpKUa9ZxHS1Q==", + "dev": true, + "requires": { + "@lerna/create-symlink": "3.16.2", + "@lerna/package": "3.16.0", + "fs-extra": "^8.1.0", + "p-map": "^2.1.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/symlink-dependencies": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@lerna/symlink-dependencies/-/symlink-dependencies-3.16.2.tgz", + "integrity": "sha512-wnZqGJQ+Jvr1I3inxrkffrFZfmQI7Ta8gySw/UWCy95QtZWF/f5yk8zVIocCAsjzD0wgb3jJE3CFJ9W5iwWk1A==", + "dev": true, + "requires": { + "@lerna/create-symlink": "3.16.2", + "@lerna/resolve-symlink": "3.16.0", + "@lerna/symlink-binary": "3.16.2", + "fs-extra": "^8.1.0", + "p-finally": "^1.0.0", + "p-map": "^2.1.0", + "p-map-series": "^1.0.0" + }, + "dependencies": { + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "@lerna/timer": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/timer/-/timer-3.13.0.tgz", + "integrity": "sha512-RHWrDl8U4XNPqY5MQHkToWS9jHPnkLZEt5VD+uunCKTfzlxGnRCr3/zVr8VGy/uENMYpVP3wJa4RKGY6M0vkRw==", + "dev": true + }, + "@lerna/validation-error": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/validation-error/-/validation-error-3.13.0.tgz", + "integrity": "sha512-SiJP75nwB8GhgwLKQfdkSnDufAaCbkZWJqEDlKOUPUvVOplRGnfL+BPQZH5nvq2BYSRXsksXWZ4UHVnQZI/HYA==", + "dev": true, + "requires": { + "npmlog": "^4.1.2" + } + }, + "@lerna/version": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/@lerna/version/-/version-3.16.4.tgz", + "integrity": "sha512-ikhbMeIn5ljCtWTlHDzO4YvTmpGTX1lWFFIZ79Vd1TNyOr+OUuKLo/+p06mCl2WEdZu0W2s5E9oxfAAQbyDxEg==", + "dev": true, + "requires": { + "@lerna/check-working-tree": "3.14.2", + "@lerna/child-process": "3.14.2", + "@lerna/collect-updates": "3.16.0", + "@lerna/command": "3.16.0", + "@lerna/conventional-commits": "3.16.4", + "@lerna/github-client": "3.16.0", + "@lerna/gitlab-client": "3.15.0", + "@lerna/output": "3.13.0", + "@lerna/prerelease-id-from-version": "3.16.0", + "@lerna/prompt": "3.13.0", + "@lerna/run-lifecycle": "3.16.2", + "@lerna/run-topologically": "3.16.0", + "@lerna/validation-error": "3.13.0", + "chalk": "^2.3.1", + "dedent": "^0.7.0", + "minimatch": "^3.0.4", + "npmlog": "^4.1.2", + "p-map": "^2.1.0", + "p-pipe": "^1.2.0", + "p-reduce": "^1.0.0", + "p-waterfall": "^1.0.0", + "semver": "^6.2.0", + "slash": "^2.0.0", + "temp-write": "^3.4.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@lerna/write-log-file": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@lerna/write-log-file/-/write-log-file-3.13.0.tgz", + "integrity": "sha512-RibeMnDPvlL8bFYW5C8cs4mbI3AHfQef73tnJCQ/SgrXZHehmHnsyWUiE7qDQCAo+B1RfTapvSyFF69iPj326A==", + "dev": true, + "requires": { + "npmlog": "^4.1.2", + "write-file-atomic": "^2.3.0" + } + }, + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.2.tgz", + "integrity": "sha512-wrIBsjA5pl13f0RN4Zx4FNWmU71lv03meGKnqRUoCyan17s4V3WL92f3w3AIuWbNnpcrQyFBU5qMavJoB8d27w==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.2", + "run-parallel": "^1.1.9" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.2.tgz", + "integrity": "sha512-z8+wGWV2dgUhLqrtRYa03yDx4HWMvXKi1z8g3m2JyxAx8F7xk74asqPk5LAETjqDSGLFML/6CDl0+yFunSYicw==", + "dev": true + } + } + }, + "@nodelib/fs.stat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", + "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.3.tgz", + "integrity": "sha512-l6t8xEhfK9Sa4YO5mIRdau7XSOADfmh3jCr0evNHdY+HNkW6xuQhgMH7D73VV6WpZOagrW0UludvMTiifiwTfA==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.2", + "fastq": "^1.6.0" + } + }, + "@octokit/endpoint": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-5.1.2.tgz", + "integrity": "sha512-bBGGmcRFq1x0jrB29G/9KjYmO3cdHfk3476B2JOHRvLsNw1Pn3l+ZvbiqtcO9qAS4Ti+zFedLB84ziHZRZclQA==", + "dev": true, + "requires": { + "deepmerge": "3.2.0", + "is-plain-object": "^3.0.0", + "universal-user-agent": "^2.1.0", + "url-template": "^2.0.8" + }, + "dependencies": { + "deepmerge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.2.0.tgz", + "integrity": "sha512-6+LuZGU7QCNUnAJyX8cIrlzoEgggTM6B7mm+znKOX4t5ltluT9KLjN6g61ECMS0LTsLW7yDpNoxhix5FZcrIow==", + "dev": true + }, + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + } + } + }, + "@octokit/graphql": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-2.1.3.tgz", + "integrity": "sha512-XoXJqL2ondwdnMIW3wtqJWEwcBfKk37jO/rYkoxNPEVeLBDGsGO1TCWggrAlq3keGt/O+C/7VepXnukUxwt5vA==", + "dev": true, + "requires": { + "@octokit/request": "^5.0.0", + "universal-user-agent": "^2.0.3" + }, + "dependencies": { + "@octokit/request": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.0.2.tgz", + "integrity": "sha512-z1BQr43g4kOL4ZrIVBMHwi68Yg9VbkRUyuAgqCp1rU3vbYa69+2gIld/+gHclw15bJWQnhqqyEb7h5a5EqgZ0A==", + "dev": true, + "requires": { + "@octokit/endpoint": "^5.1.0", + "@octokit/request-error": "^1.0.1", + "deprecation": "^2.0.0", + "is-plain-object": "^3.0.0", + "node-fetch": "^2.3.0", + "once": "^1.4.0", + "universal-user-agent": "^3.0.0" + }, + "dependencies": { + "universal-user-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-3.0.0.tgz", + "integrity": "sha512-T3siHThqoj5X0benA5H0qcDnrKGXzU8TKoX15x/tQHw1hQBvIEBHjxQ2klizYsqBOO/Q+WuxoQUihadeeqDnoA==", + "dev": true, + "requires": { + "os-name": "^3.0.0" + } + } + } + }, + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + } + } + }, + "@octokit/plugin-enterprise-rest": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-3.6.2.tgz", + "integrity": "sha512-3wF5eueS5OHQYuAEudkpN+xVeUsg8vYEMMenEzLphUZ7PRZ8OJtDcsreL3ad9zxXmBbaFWzLmFcdob5CLyZftA==", + "dev": true + }, + "@octokit/request": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-4.1.0.tgz", + "integrity": "sha512-RvpQAba4i+BNH0z8i0gPRc1ShlHidj4puQjI/Tno6s+Q3/Mzb0XRSHJiOhpeFrZ22V7Mwjq1E7QS27P5CgpWYA==", + "dev": true, + "requires": { + "@octokit/endpoint": "^5.1.0", + "@octokit/request-error": "^1.0.1", + "deprecation": "^2.0.0", + "is-plain-object": "^3.0.0", + "node-fetch": "^2.3.0", + "once": "^1.4.0", + "universal-user-agent": "^2.1.0" + }, + "dependencies": { + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + } + } + }, + "@octokit/request-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.0.2.tgz", + "integrity": "sha512-T9swMS/Vc4QlfWrvyeSyp/GjhXtYaBzCcibjGywV4k4D2qVrQKfEMPy8OxMDEj7zkIIdpHwqdpVbKCvnUPqkXw==", + "dev": true, + "requires": { + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/rest": { + "version": "16.26.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.26.0.tgz", + "integrity": "sha512-NBpzre44ZAQWZhlH+zUYTgqI0pHN+c9rNj4d+pCydGEiKTGc1HKmoTghEUyr9GxazDyoAvmpx9nL0I7QS1Olvg==", + "dev": true, + "requires": { + "@octokit/request": "^4.0.1", + "@octokit/request-error": "^1.0.2", + "atob-lite": "^2.0.0", + "before-after-hook": "^1.4.0", + "btoa-lite": "^1.0.0", + "deprecation": "^2.0.0", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.uniq": "^4.5.0", + "octokit-pagination-methods": "^1.1.0", + "once": "^1.4.0", + "universal-user-agent": "^2.0.0", + "url-template": "^2.0.8" + } + }, + "@parcel/fs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/fs/-/fs-1.11.0.tgz", + "integrity": "sha512-86RyEqULbbVoeo8OLcv+LQ1Vq2PKBAvWTU9fCgALxuCTbbs5Ppcvll4Vr+Ko1AnmMzja/k++SzNAwJfeQXVlpA==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "mkdirp": "^0.5.1", + "rimraf": "^2.6.2" + } + }, + "@parcel/logger": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/logger/-/logger-1.11.0.tgz", + "integrity": "sha512-lIRfDg+junbFUUeU0QtHX00gKCgEsYHZydFKwrJ8dc0D+WE2SYT1FcVCgpPAfKYgtg0QQMns8E9vzT9UjH92PQ==", + "dev": true, + "requires": { + "@parcel/workers": "^1.11.0", + "chalk": "^2.1.0", + "grapheme-breaker": "^0.3.2", + "ora": "^2.1.0", + "strip-ansi": "^4.0.0" + } + }, + "@parcel/utils": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/utils/-/utils-1.11.0.tgz", + "integrity": "sha512-cA3p4jTlaMeOtAKR/6AadanOPvKeg8VwgnHhOyfi0yClD0TZS/hi9xu12w4EzA/8NtHu0g6o4RDfcNjqN8l1AQ==", + "dev": true + }, + "@parcel/watcher": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-1.12.0.tgz", + "integrity": "sha512-yijGiAqG7Tjf5WnFwOkiNWwerfZQDNABldiiqRDtr7vDWLO+F/DIncyB7tTcaD5Loevrr5mzzGo8Ntf3d2GIPg==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "chokidar": "^2.0.3" + } + }, + "@parcel/workers": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/workers/-/workers-1.11.0.tgz", + "integrity": "sha512-USSjRAAQYsZFlv43FUPdD+jEGML5/8oLF0rUzPQTtK4q9kvaXr49F5ZplyLz5lox78cLZ0TxN2bIDQ1xhOkulQ==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "physical-cpu-count": "^2.0.0" + } + }, + "@react-native-community/cli-platform-android": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-2.1.1.tgz", + "integrity": "sha512-M6HNSJYTPNAt8iV8RiclSySyBavo9dsMKAIvKsHJqwbIRNWbRPHONQ71hvw7PzxC+RTwFTHUFKMotWgjKGEKPw==", + "dev": true, + "requires": { + "@react-native-community/cli-tools": "^2.0.2", + "logkitty": "^0.5.0", + "slash": "^2.0.0", + "xmldoc": "^0.4.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@react-native-community/cli-platform-ios": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-2.2.0.tgz", + "integrity": "sha512-rgqksYcolGZNkG4EnWEX2nECWyTxYRUZXRb+pXc4niBsCvNMU88sXoMTxFIHb3wOAaVT2Eix3nS78yG6TA7dnA==", + "dev": true, + "requires": { + "@react-native-community/cli-tools": "^2.0.2", + "chalk": "^1.1.1", + "xcode": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "@react-native-community/cli-tools": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-2.0.2.tgz", + "integrity": "sha512-6OOKrE1Sdq1Lmcdp2K68J5PsG5G80a9USa9I1Kv92wvPHUup6IRt+Dy7E8IZqxmskzC/mlOR62Oh1CB3sFm84g==", + "dev": true, + "requires": { + "chalk": "^1.1.1", + "lodash": "^4.17.5", + "mime": "^2.4.1", + "node-fetch": "^2.5.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "mime": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "@samverschueren/stream-to-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz", + "integrity": "sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg==", + "dev": true, + "requires": { + "any-observable": "^0.3.0" + } + }, + "@tannin/compile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.0.3.tgz", + "integrity": "sha512-OkPHvaM/hIHdSco3+ZO1hzkOtfEddn5a0veWft2aDLvKnbdj9VusiLKNdEE9by3hCZIIcb9aWF+iBorhfrQOfw==", + "requires": { + "@tannin/evaluate": "^1.1.1", + "@tannin/postfix": "^1.0.2" + } + }, + "@tannin/evaluate": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@tannin/evaluate/-/evaluate-1.1.1.tgz", + "integrity": "sha512-ALuSZHjrLHGnw0WxsHDHde74FJ2WW0Ck4rg3QBxFBCmxd6Wsac+e0HXfJ++Qion15LIOCmFhyVpWzawMgeBA8Q==" + }, + "@tannin/plural-forms": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tannin/plural-forms/-/plural-forms-1.0.3.tgz", + "integrity": "sha512-IUr9+FiCnzCiB9aRio3FVNR8TNL9SmX2zkV6tmfWWwSclX4uTCykoGsDhTGKK+sZnMrdPCTmb/OxbtGNdVyV4g==", + "requires": { + "@tannin/compile": "^1.0.3" + } + }, + "@tannin/postfix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.0.2.tgz", + "integrity": "sha512-Nggtk7/ljfNPpAX8CjxxLkMKuO6u2gH1ozmTvGclWF2pNcxTf6YGghYNYNWZRKrimXGhQ8yZqvAHep7h80K04g==" + }, + "@types/babel__core": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.2.tgz", + "integrity": "sha512-cfCCrFmiGY/yq0NuKNxIQvZFy9kY/1immpSpTngOnyIbD4+eJOG5mxphhHDv3CHL9GltO4GcKr54kGBg3RNdbg==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.0.2.tgz", + "integrity": "sha512-NHcOfab3Zw4q5sEE2COkpfXjoE7o+PmqD9DQW4koUT3roNxwziUdXGnRndMat/LJNUtePwn1TlP4do3uoe3KZQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.0.2.tgz", + "integrity": "sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.7.tgz", + "integrity": "sha512-CeBpmX1J8kWLcDEnI3Cl2Eo6RfbGvzUctA+CjZUhOKDFbLfcr7fc4usEqLNWetrlJd7RhAkyYe2czXop4fICpw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", + "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.0.tgz", + "integrity": "sha512-eAtOAFZefEnfJiRFQBGw1eYqa5GTLCZ1y86N0XSI/D6EB+E8z6VPV/UL7Gi5UEclFqoQk+6NRqEDsfmDLXn8sg==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz", + "integrity": "sha512-3BUTyMzbZa2DtDI2BkERNC6jJw2Mr2Y0oGI7mRxYNBPxppbtEK1F66u3bKwU2g+wxwWI7PAoRpJnOY1grJqzHg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz", + "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "@types/json-schema": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz", + "integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==", + "dev": true + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, + "@types/node": { + "version": "10.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.2.tgz", + "integrity": "sha512-53ElVDSnZeFUUFIYzI8WLQ25IhWzb6vbddNp8UHlXQyU0ET2RhV5zg0NfubzU7iNMh5bBXb0htCzfvrSVNgzaQ==", + "dev": true + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "@types/q": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", + "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==", + "dev": true + }, + "@types/stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", + "dev": true + }, + "@types/unist": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.2.tgz", + "integrity": "sha512-iHI60IbyfQilNubmxsq4zqSjdynlmc2Q/QvH9kjzg9+CCYVVzq1O6tc7VBzSygIwnmOt07w80IG6HDQvjv3Liw==", + "dev": true + }, + "@types/vfile": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/vfile/-/vfile-3.0.2.tgz", + "integrity": "sha512-b3nLFGaGkJ9rzOcuXRfHkZMdjsawuDD0ENL9fzTophtBg8FJHSGbH7daXkEpcwy3v7Xol3pAvsmlYyFhR4pqJw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/unist": "*", + "@types/vfile-message": "*" + } + }, + "@types/vfile-message": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/vfile-message/-/vfile-message-1.0.1.tgz", + "integrity": "sha512-mlGER3Aqmq7bqR1tTTIVHq8KSAFFRyGbrxuM8C/H82g6k7r2fS+IMEkIu3D7JHzG10NvPdR8DNx0jr0pwpp4dA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/unist": "*" + } + }, + "@types/yargs": { + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz", + "integrity": "sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw==", + "dev": true + }, + "@typescript-eslint/experimental-utils": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-1.13.0.tgz", + "integrity": "sha512-zmpS6SyqG4ZF64ffaJ6uah6tWWWgZ8m+c54XXgwFtUv0jNz8aJAVx8chMCvnk7yl6xwn8d+d96+tWp7fXzTuDg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/typescript-estree": "1.13.0", + "eslint-scope": "^4.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + } + } + }, + "@typescript-eslint/typescript-estree": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-1.13.0.tgz", + "integrity": "sha512-b5rCmd2e6DCC6tCTN9GSUAuxdYwCM/k/2wdjHGrIRGPSJotWMCe/dGpi66u42bhuh8q3QBzqM4TMA1GUUCJvdw==", + "dev": true, + "requires": { + "lodash.unescape": "4.0.1", + "semver": "5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "dev": true + } + } + }, + "@webassemblyjs/ast": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", + "integrity": "sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ==", + "dev": true, + "requires": { + "@webassemblyjs/helper-module-context": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/wast-parser": "1.8.5" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz", + "integrity": "sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz", + "integrity": "sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz", + "integrity": "sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q==", + "dev": true + }, + "@webassemblyjs/helper-code-frame": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz", + "integrity": "sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ==", + "dev": true, + "requires": { + "@webassemblyjs/wast-printer": "1.8.5" + } + }, + "@webassemblyjs/helper-fsm": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz", + "integrity": "sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow==", + "dev": true + }, + "@webassemblyjs/helper-module-context": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz", + "integrity": "sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "mamacro": "^0.0.3" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz", + "integrity": "sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz", + "integrity": "sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz", + "integrity": "sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.8.5.tgz", + "integrity": "sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.8.5.tgz", + "integrity": "sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz", + "integrity": "sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/helper-wasm-section": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5", + "@webassemblyjs/wasm-opt": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5", + "@webassemblyjs/wast-printer": "1.8.5" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz", + "integrity": "sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/ieee754": "1.8.5", + "@webassemblyjs/leb128": "1.8.5", + "@webassemblyjs/utf8": "1.8.5" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz", + "integrity": "sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz", + "integrity": "sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-api-error": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/ieee754": "1.8.5", + "@webassemblyjs/leb128": "1.8.5", + "@webassemblyjs/utf8": "1.8.5" + } + }, + "@webassemblyjs/wast-parser": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz", + "integrity": "sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/floating-point-hex-parser": "1.8.5", + "@webassemblyjs/helper-api-error": "1.8.5", + "@webassemblyjs/helper-code-frame": "1.8.5", + "@webassemblyjs/helper-fsm": "1.8.5", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz", + "integrity": "sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/wast-parser": "1.8.5", + "@xtuc/long": "4.2.2" + } + }, + "@wordpress/a11y": { + "version": "file:packages/a11y", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/dom-ready": "file:packages/dom-ready" + } + }, + "@wordpress/annotations": { + "version": "file:packages/annotations", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/data": "file:packages/data", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/rich-text": "file:packages/rich-text", + "lodash": "^4.17.15", + "rememo": "^3.0.0", + "uuid": "^3.3.2" + } + }, + "@wordpress/api-fetch": { + "version": "file:packages/api-fetch", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/url": "file:packages/url" + } + }, + "@wordpress/autop": { + "version": "file:packages/autop", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/babel-plugin-import-jsx-pragma": { + "version": "file:packages/babel-plugin-import-jsx-pragma", + "dev": true + }, + "@wordpress/babel-plugin-makepot": { + "version": "file:packages/babel-plugin-makepot", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "gettext-parser": "^1.3.1", + "lodash": "^4.17.15" + } + }, + "@wordpress/babel-preset-default": { + "version": "file:packages/babel-preset-default", + "dev": true, + "requires": { + "@babel/core": "^7.4.5", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.4.4", + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/plugin-transform-runtime": "^7.4.4", + "@babel/preset-env": "^7.4.5", + "@babel/runtime": "^7.4.5", + "@wordpress/babel-plugin-import-jsx-pragma": "file:packages/babel-plugin-import-jsx-pragma", + "@wordpress/browserslist-config": "file:packages/browserslist-config", + "@wordpress/element": "file:packages/element", + "core-js": "^3.1.4" + } + }, + "@wordpress/blob": { + "version": "file:packages/blob", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/block-directory": { + "version": "file:packages/block-directory", + "requires": { + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.15" + } + }, + "@wordpress/block-editor": { + "version": "file:packages/block-editor", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/blob": "file:packages/blob", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/dom": "file:packages/dom", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/html-entities": "file:packages/html-entities", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/token-list": "file:packages/token-list", + "@wordpress/url": "file:packages/url", + "@wordpress/viewport": "file:packages/viewport", + "@wordpress/wordcount": "file:packages/wordcount", + "classnames": "^2.2.5", + "diff": "^3.5.0", + "dom-scroll-into-view": "^1.2.1", + "inherits": "^2.0.3", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "react-autosize-textarea": "^3.0.2", + "react-spring": "^8.0.19", + "redux-multi": "^0.1.12", + "refx": "^3.0.0", + "rememo": "^3.0.0", + "tinycolor2": "^1.4.1", + "traverse": "^0.6.6" + } + }, + "@wordpress/block-library": { + "version": "file:packages/block-library", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/autop": "file:packages/autop", + "@wordpress/blob": "file:packages/blob", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/core-data": "file:packages/core-data", + "@wordpress/data": "file:packages/data", + "@wordpress/date": "file:packages/date", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/editor": "file:packages/editor", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/server-side-render": "file:packages/server-side-render", + "@wordpress/url": "file:packages/url", + "@wordpress/viewport": "file:packages/viewport", + "classnames": "^2.2.5", + "fast-average-color": "4.3.0", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "moment": "^2.22.1", + "tinycolor2": "^1.4.1", + "url": "^0.11.0" + } + }, + "@wordpress/block-serialization-default-parser": { + "version": "file:packages/block-serialization-default-parser", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/block-serialization-spec-parser": { + "version": "file:packages/block-serialization-spec-parser", + "requires": { + "pegjs": "^0.10.0", + "phpegjs": "^1.0.0-beta7" + } + }, + "@wordpress/blocks": { + "version": "file:packages/blocks", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/autop": "file:packages/autop", + "@wordpress/blob": "file:packages/blob", + "@wordpress/block-serialization-default-parser": "file:packages/block-serialization-default-parser", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/dom": "file:packages/dom", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/html-entities": "file:packages/html-entities", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/shortcode": "file:packages/shortcode", + "hpq": "^1.3.0", + "lodash": "^4.17.15", + "rememo": "^3.0.0", + "showdown": "^1.8.6", + "simple-html-tokenizer": "^0.5.7", + "tinycolor2": "^1.4.1", + "uuid": "^3.3.2" + } + }, + "@wordpress/browserslist-config": { + "version": "file:packages/browserslist-config", + "dev": true + }, + "@wordpress/components": { + "version": "file:packages/components", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/compose": "file:packages/compose", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/dom": "file:packages/dom", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", + "classnames": "^2.2.5", + "clipboard": "^2.0.1", + "dom-scroll-into-view": "^1.2.1", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "moment": "^2.22.1", + "mousetrap": "^1.6.2", + "re-resizable": "^6.0.0", + "react-dates": "^17.1.1", + "react-spring": "^8.0.20", + "rememo": "^3.0.0", + "tinycolor2": "^1.4.1", + "uuid": "^3.3.2" + } + }, + "@wordpress/compose": { + "version": "file:packages/compose", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/element": "file:packages/element", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "lodash": "^4.17.15" + } + }, + "@wordpress/core-data": { + "version": "file:packages/core-data", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/data": "file:packages/data", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/url": "file:packages/url", + "equivalent-key-map": "^0.2.2", + "lodash": "^4.17.15", + "rememo": "^3.0.0" + } + }, + "@wordpress/custom-templated-path-webpack-plugin": { + "version": "file:packages/custom-templated-path-webpack-plugin", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "@wordpress/data": { + "version": "file:packages/data", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/compose": "file:packages/compose", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/priority-queue": "file:packages/priority-queue", + "@wordpress/redux-routine": "file:packages/redux-routine", + "equivalent-key-map": "^0.2.2", + "is-promise": "^2.1.0", + "lodash": "^4.17.15", + "redux": "^4.0.0", + "turbo-combine-reducers": "^1.0.2" + } + }, + "@wordpress/data-controls": { + "version": "file:packages/data-controls", + "requires": { + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/data": "file:packages/data" + } + }, + "@wordpress/date": { + "version": "file:packages/date", + "requires": { + "@babel/runtime": "^7.4.4", + "moment": "^2.22.1", + "moment-timezone": "^0.5.16" + } + }, + "@wordpress/dependency-extraction-webpack-plugin": { + "version": "file:packages/dependency-extraction-webpack-plugin", + "dev": true, + "requires": { + "json2php": "^0.0.4", + "webpack": "^4.8.3", + "webpack-sources": "^1.3.0" + } + }, + "@wordpress/deprecated": { + "version": "file:packages/deprecated", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/hooks": "file:packages/hooks" + } + }, + "@wordpress/docgen": { + "version": "file:packages/docgen", + "dev": true, + "requires": { + "doctrine": "^2.1.0", + "espree": "^4.0.0", + "lodash": "^4.17.15", + "mdast-util-inject": "1.1.0", + "optionator": "0.8.2", + "remark": "10.0.1", + "remark-parse": "6.0.3", + "unified": "7.1.0" + } + }, + "@wordpress/dom": { + "version": "file:packages/dom", + "requires": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15" + } + }, + "@wordpress/dom-ready": { + "version": "file:packages/dom-ready", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/e2e-test-utils": { + "version": "file:packages/e2e-test-utils", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/url": "file:packages/url", + "lodash": "^4.17.15", + "node-fetch": "^1.7.3" + } + }, + "@wordpress/e2e-tests": { + "version": "file:packages/e2e-tests", + "dev": true, + "requires": { + "@wordpress/e2e-test-utils": "file:packages/e2e-test-utils", + "@wordpress/jest-console": "file:packages/jest-console", + "@wordpress/jest-puppeteer-axe": "file:packages/jest-puppeteer-axe", + "@wordpress/scripts": "file:packages/scripts", + "expect-puppeteer": "^4.3.0", + "lodash": "^4.17.15", + "uuid": "^3.3.2" + } + }, + "@wordpress/edit-post": { + "version": "file:packages/edit-post", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/block-library": "file:packages/block-library", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/core-data": "file:packages/core-data", + "@wordpress/data": "file:packages/data", + "@wordpress/editor": "file:packages/editor", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/media-utils": "file:packages/media-utils", + "@wordpress/notices": "file:packages/notices", + "@wordpress/nux": "file:packages/nux", + "@wordpress/plugins": "file:packages/plugins", + "@wordpress/url": "file:packages/url", + "@wordpress/viewport": "file:packages/viewport", + "classnames": "^2.2.5", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "refx": "^3.0.0", + "rememo": "^3.0.0" + } + }, + "@wordpress/edit-widgets": { + "version": "file:packages/edit-widgets", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/block-library": "file:packages/block-library", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/data-controls": "file:packages/data-controls", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/media-utils": "file:packages/media-utils", + "@wordpress/notices": "file:packages/notices", + "lodash": "^4.17.15", + "rememo": "^3.0.0" + } + }, + "@wordpress/editor": { + "version": "file:packages/editor", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/autop": "file:packages/autop", + "@wordpress/blob": "file:packages/blob", + "@wordpress/block-directory": "file:packages/block-directory", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/core-data": "file:packages/core-data", + "@wordpress/data": "file:packages/data", + "@wordpress/data-controls": "file:packages/data-controls", + "@wordpress/date": "file:packages/date", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/html-entities": "file:packages/html-entities", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/media-utils": "file:packages/media-utils", + "@wordpress/notices": "file:packages/notices", + "@wordpress/nux": "file:packages/nux", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/server-side-render": "file:packages/server-side-render", + "@wordpress/url": "file:packages/url", + "@wordpress/viewport": "file:packages/viewport", + "@wordpress/wordcount": "file:packages/wordcount", + "classnames": "^2.2.5", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "react-autosize-textarea": "^3.0.2", + "redux-optimist": "^1.0.0", + "refx": "^3.0.0", + "rememo": "^3.0.0" + } + }, + "@wordpress/element": { + "version": "file:packages/element", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/escape-html": "file:packages/escape-html", + "lodash": "^4.17.15", + "react": "^16.9.0", + "react-dom": "^16.9.0" + } + }, + "@wordpress/escape-html": { + "version": "file:packages/escape-html", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/eslint-plugin": { + "version": "file:packages/eslint-plugin", + "dev": true, + "requires": { + "babel-eslint": "^10.0.2", + "eslint-plugin-jest": "^22.15.1", + "eslint-plugin-jsdoc": "^15.8.0", + "eslint-plugin-jsx-a11y": "^6.2.3", + "eslint-plugin-react": "^7.14.3", + "eslint-plugin-react-hooks": "^1.6.1", + "globals": "^12.0.0", + "requireindex": "^1.2.0" + } + }, + "@wordpress/format-library": { + "version": "file:packages/format-library", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/components": "file:packages/components", + "@wordpress/dom": "file:packages/dom", + "@wordpress/element": "file:packages/element", + "@wordpress/html-entities": "file:packages/html-entities", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/url": "file:packages/url", + "lodash": "^4.17.15" + } + }, + "@wordpress/hooks": { + "version": "file:packages/hooks", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/html-entities": { + "version": "file:packages/html-entities", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/i18n": { + "version": "file:packages/i18n", + "requires": { + "@babel/runtime": "^7.4.4", + "gettext-parser": "^1.3.1", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "sprintf-js": "^1.1.1", + "tannin": "^1.1.0" + } + }, + "@wordpress/is-shallow-equal": { + "version": "file:packages/is-shallow-equal", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/jest-console": { + "version": "file:packages/jest-console", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "jest-matcher-utils": "^24.7.0", + "lodash": "^4.17.15" + } + }, + "@wordpress/jest-preset-default": { + "version": "file:packages/jest-preset-default", + "dev": true, + "requires": { + "@wordpress/jest-console": "file:packages/jest-console", + "babel-jest": "^24.7.1", + "enzyme": "^3.9.0", + "enzyme-adapter-react-16": "^1.10.0", + "enzyme-to-json": "^3.3.5" + } + }, + "@wordpress/jest-puppeteer-axe": { + "version": "file:packages/jest-puppeteer-axe", + "dev": true, + "requires": { + "axe-puppeteer": "^1.0.0" + } + }, + "@wordpress/keycodes": { + "version": "file:packages/keycodes", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.15" + } + }, + "@wordpress/library-export-default-webpack-plugin": { + "version": "file:packages/library-export-default-webpack-plugin", + "dev": true, + "requires": { + "lodash": "^4.17.15", + "webpack-sources": "^1.1.0" + } + }, + "@wordpress/list-reusable-blocks": { + "version": "file:packages/list-reusable-blocks", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.15" + } + }, + "@wordpress/media-utils": { + "version": "file:packages/media-utils", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/blob": "file:packages/blob", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.15" + } + }, + "@wordpress/notices": { + "version": "file:packages/notices", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/data": "file:packages/data", + "lodash": "^4.17.15" + } + }, + "@wordpress/npm-package-json-lint-config": { + "version": "file:packages/npm-package-json-lint-config", + "dev": true + }, + "@wordpress/nux": { + "version": "file:packages/nux", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.15", + "rememo": "^3.0.0" + } + }, + "@wordpress/plugins": { + "version": "file:packages/plugins", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/compose": "file:packages/compose", + "@wordpress/element": "file:packages/element", + "@wordpress/hooks": "file:packages/hooks", + "lodash": "^4.17.15" + } + }, + "@wordpress/postcss-themes": { + "version": "file:packages/postcss-themes", + "dev": true, + "requires": { + "autoprefixer": "^9.4.5", + "postcss": "^7.0.13", + "postcss-color-function": "^4.0.1" + } + }, + "@wordpress/priority-queue": { + "version": "file:packages/priority-queue", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@wordpress/redux-routine": { + "version": "file:packages/redux-routine", + "requires": { + "@babel/runtime": "^7.4.4", + "is-promise": "^2.1.0", + "lodash": "^4.17.15", + "rungen": "^0.3.2" + } + }, + "@wordpress/rich-text": { + "version": "file:packages/rich-text", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", + "@wordpress/escape-html": "file:packages/escape-html", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "classnames": "^2.2.5", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "rememo": "^3.0.0" + } + }, + "@wordpress/scripts": { + "version": "file:packages/scripts", + "dev": true, + "requires": { + "@wordpress/babel-preset-default": "file:packages/babel-preset-default", + "@wordpress/dependency-extraction-webpack-plugin": "file:packages/dependency-extraction-webpack-plugin", + "@wordpress/eslint-plugin": "file:packages/eslint-plugin", + "@wordpress/jest-preset-default": "file:packages/jest-preset-default", + "@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config", + "babel-jest": "^24.7.1", + "babel-loader": "^8.0.5", + "chalk": "^2.4.2", + "check-node-version": "^3.1.1", + "command-exists": "^1.2.8", + "cross-spawn": "^5.1.0", + "decompress-zip": "^0.2.2", + "eslint": "^6.1.0", + "jest": "^24.7.1", + "jest-puppeteer": "^4.3.0", + "js-yaml": "^3.13.1", + "lodash": "^4.17.15", + "minimist": "^1.2.0", + "npm-package-json-lint": "^3.6.0", + "puppeteer": "^1.19.0", + "read-pkg-up": "^1.0.1", + "request": "^2.88.0", + "resolve-bin": "^0.4.0", + "source-map-loader": "^0.2.4", + "sprintf-js": "^1.1.1", + "stylelint": "^9.10.1", + "stylelint-config-wordpress": "^13.1.0", + "thread-loader": "^2.1.2", + "webpack": "^4.41.0", + "webpack-bundle-analyzer": "^3.3.2", + "webpack-cli": "^3.1.2", + "webpack-livereload-plugin": "^2.2.0" + } + }, + "@wordpress/server-side-render": { + "version": "file:packages/server-side-render", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/components": "file:packages/components", + "@wordpress/data": "file:packages/data", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/url": "file:packages/url", + "lodash": "^4.17.15" + } + }, + "@wordpress/shortcode": { + "version": "file:packages/shortcode", + "requires": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15", + "memize": "^1.0.5" + } + }, + "@wordpress/token-list": { + "version": "file:packages/token-list", + "requires": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15" + } + }, + "@wordpress/url": { + "version": "file:packages/url", + "requires": { + "@babel/runtime": "^7.4.4", + "qs": "^6.5.2" + } + }, + "@wordpress/viewport": { + "version": "file:packages/viewport", + "requires": { + "@babel/runtime": "^7.4.4", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "lodash": "^4.17.15" + } + }, + "@wordpress/wordcount": { + "version": "file:packages/wordcount", + "requires": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "@zkochan/cmd-shim": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz", + "integrity": "sha512-o8l0+x7C7sMZU3v9GuJIAU10qQLtwR1dtRQIOmlNMtyaqhmpXOzx1HWiYoWfmmf9HHZoAkXpc9TM9PQYF9d4Jg==", + "dev": true, + "requires": { + "is-windows": "^1.0.0", + "mkdirp-promise": "^5.0.1", + "mz": "^2.5.0" + } + }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "abab": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", + "integrity": "sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4=", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "absolute-path": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/absolute-path/-/absolute-path-0.0.0.tgz", + "integrity": "sha1-p4di+9rftSl76ZsV01p4Wy8JW/c=", + "dev": true + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "dev": true, + "requires": { + "mime-types": "~2.1.18", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz", + "integrity": "sha512-d+nbxBUGKg7Arpsvbnlq61mc12ek3EY8EQldM3GPAhWJ1UVxC6TDGbIvUMNU6obBX3i1+ptCIzV4vq0gFPEGVQ==", + "dev": true + }, + "acorn-globals": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.1.0.tgz", + "integrity": "sha512-KjZwU26uG3u6eZcfGbTULzFcsoz6pegNKtHPksZPOUsiKo5bUmiBPa38FuHZ/Eun+XYh/JCCkS9AS3Lu4McQOQ==", + "dev": true, + "requires": { + "acorn": "^5.0.0" + } + }, + "acorn-jsx": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-4.1.1.tgz", + "integrity": "sha512-JY+iV6r+cO21KtntVvFkD+iqjtdpRUpGqKWgfkCdZq1R+kbreEl8EcdcJR4SmiIgsIQT33s6QzheQ9a275Q8xw==", + "dev": true, + "requires": { + "acorn": "^5.0.3" + } + }, + "acorn-walk": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.1.1.tgz", + "integrity": "sha512-OtUw6JUTgxA2QoqqmrmQ7F2NYqiBPi/L2jqHyFtllhOUvXYQXf0Z1CYUinIfyT4bTCGmrA7gX9FvHA81uzCoVw==", + "dev": true + }, + "agent-base": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", + "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "dev": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "agentkeepalive": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.2.tgz", + "integrity": "sha512-e0L/HNe6qkQ7H19kTlRRqUibEAwDK5AFk6y3PtMsuut2VAH6+Q4xZml1tNDJD7kSAyqmbG/K08K5WEJYtUrSlQ==", + "dev": true, + "requires": { + "humanize-ms": "^1.2.1" + } + }, + "aggregate-error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.0.tgz", + "integrity": "sha512-yKD9kEoJIR+2IFqhMwayIBgheLYbB3PS2OBhWae1L/ODTd/JF/30cW0bc9TqzRL3k4U41Dieu3BF4I29p8xesA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^3.2.0" + } + }, + "airbnb-prop-types": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.10.0.tgz", + "integrity": "sha512-M7kDqFO6kFNGV0fHPZaBx672m0jwbpCdbrtW2lcevCEuPB2sKCY3IPa030K/N1iJLEGwCNk4NSag65XBEulwhg==", + "requires": { + "array.prototype.find": "^2.0.4", + "function.prototype.name": "^1.1.0", + "has": "^1.0.1", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object.assign": "^4.1.0", + "object.entries": "^1.0.4", + "prop-types": "^15.6.1", + "prop-types-exact": "^1.1.2" + } + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-errors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", + "dev": true + }, + "ajv-keywords": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz", + "integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==", + "dev": true + }, + "alphanum-sort": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", + "dev": true + }, + "ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "requires": { + "ansi-wrap": "^0.1.0" + } + }, + "ansi-cyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-cyan/-/ansi-cyan-0.1.1.tgz", + "integrity": "sha1-U4rlKK+JgvKK4w2G8vF0VtJgmHM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-escapes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", + "dev": true + }, + "ansi-fragments": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", + "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", + "dev": true, + "requires": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansi-to-html": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.10.tgz", + "integrity": "sha512-znsY3gvsk4CiApWu1yVYF8Nx5Vy0FEe8B0YwyxdbCdErJu5lfKlRHB2twtUjR+dxR4WewTk2OP8XqTmWYnImOg==", + "dev": true, + "requires": { + "entities": "^1.1.1" + } + }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, + "any-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", + "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", + "dev": true + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "append-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", + "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "dev": true, + "requires": { + "default-require-extensions": "^2.0.0" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + } + } + }, + "aria-query": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz", + "integrity": "sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w=", + "dev": true, + "requires": { + "ast-types-flow": "0.0.7", + "commander": "^2.11.0" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-differ": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-2.1.0.tgz", + "integrity": "sha512-KbUpJgx909ZscOc/7CLATBFam7P1Z1QRQInvgT0UztM9Q72aGKCunKASAl7WNW0tnPmPyEMeMhdsfWhfmW037w==", + "dev": true + }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", + "dev": true + }, + "array-filter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-1.0.0.tgz", + "integrity": "sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "dev": true + }, + "array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "dev": true + }, + "array-includes": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz", + "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.7.0" + } + }, + "array-iterate": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-1.1.2.tgz", + "integrity": "sha512-1hWSHTIlG/8wtYD+PPX5AOBtKWngpDFjrsrHgZpe+JdgNGz0udYu6ZIkAa/xuenIUEqFv7DvE2Yr60jxweJSrQ==", + "dev": true + }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", + "dev": true + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", + "dev": true + }, + "array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU=", + "dev": true + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "array.prototype.find": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.0.tgz", + "integrity": "sha512-Wn41+K1yuO5p7wRZDl7890c3xvv5UBrfVXTVIe28rSQb6LS0fZMDrQB6PAcxQFRFy6vJTLDc3A2+3CjQdzVKRg==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.13.0" + }, + "dependencies": { + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "requires": { + "object-keys": "^1.0.12" + } + }, + "es-abstract": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", + "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "requires": { + "es-to-primitive": "^1.2.0", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "is-callable": "^1.1.4", + "is-regex": "^1.0.4", + "object-keys": "^1.0.12" + } + }, + "es-to-primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", + "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "is-symbol": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", + "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "requires": { + "has-symbols": "^1.0.0" + } + } + } + }, + "array.prototype.flat": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.1.tgz", + "integrity": "sha512-rVqIs330nLJvfC7JqYvEWwqVr5QjYF1ib02i3YJtR/fICO6527Tjpc/e4Mvmxh3GIePPreRXMdaGyC99YphWEw==", + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.10.0", + "function-bind": "^1.1.1" + } + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "art": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/art/-/art-0.10.3.tgz", + "integrity": "sha512-HXwbdofRTiJT6qZX/FnchtldzJjS3vkLJxQilc3Xj+ma2MXjY4UAyQ0ls1XZYVnDvVIBiFZbC6QsvtW86TD6tQ==", + "dev": true + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", + "dev": true + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "dev": true, + "requires": { + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "ast-types-flow": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=", + "dev": true + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "dev": true, + "requires": { + "lodash": "^4.17.10" + } + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==", + "dev": true + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.1.tgz", + "integrity": "sha1-ri1acpR38onWDdf5amMUoi3Wwio=", + "dev": true + }, + "atob-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", + "integrity": "sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=", + "dev": true + }, + "autoprefixer": { + "version": "9.4.5", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.4.5.tgz", + "integrity": "sha512-M602C0ZxzFpJKqD4V6eq2j+K5CkzlhekCrcQupJmAOrPEZjWJyj/wSeo6qRSNoN6M3/9mtLPQqTTrABfReytQg==", + "dev": true, + "requires": { + "browserslist": "^4.4.0", + "caniuse-lite": "^1.0.30000928", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "postcss": "^7.0.11", + "postcss-value-parser": "^3.3.1" + }, + "dependencies": { + "caniuse-lite": { + "version": "1.0.30000929", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000929.tgz", + "integrity": "sha512-n2w1gPQSsYyorSVYqPMqbSaz1w7o9ZC8VhOEGI9T5MfGDzp7sbopQxG6GaQmYsaq13Xfx/mkxJUWC1Dz3oZfzw==", + "dev": true + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "autosize": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/autosize/-/autosize-4.0.2.tgz", + "integrity": "sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA==" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "axe-core": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-3.1.2.tgz", + "integrity": "sha512-e1WVs0SQu3tM29J9a/mISGvlo2kdCStE+yffIAJF6eb42FS+eUFEVz9j4rgDeV2TAfPJmuOZdRetWYycIbK7Vg==", + "dev": true + }, + "axe-puppeteer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/axe-puppeteer/-/axe-puppeteer-1.0.0.tgz", + "integrity": "sha512-hTF3u4mtatgTN7fsLVyVgbRdNc15ngjDcTEuqhn9A7ugqLhLCryJWp9fzqZkNlrW8awPcxugyTwLPR7mRdPZmA==", + "dev": true, + "requires": { + "axe-core": "^3.1.2" + } + }, + "axobject-query": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz", + "integrity": "sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww==", + "dev": true, + "requires": { + "ast-types-flow": "0.0.7" + } + }, + "babel-eslint": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.0.2.tgz", + "integrity": "sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "eslint-scope": "3.7.1", + "eslint-visitor-keys": "^1.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz", + "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + } + } + }, + "babel-jest": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.8.0.tgz", + "integrity": "sha512-+5/kaZt4I9efoXzPlZASyK/lN9qdRKmmUav9smVc0ruPQD7IsfucQ87gpOE8mn2jbDuS6M/YOW6n3v9ZoIfgnw==", + "dev": true, + "requires": { + "@jest/transform": "^24.8.0", + "@jest/types": "^24.8.0", + "@types/babel__core": "^7.1.0", + "babel-plugin-istanbul": "^5.1.0", + "babel-preset-jest": "^24.6.0", + "chalk": "^2.4.2", + "slash": "^2.0.0" + }, + "dependencies": { + "@jest/fake-timers": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-24.8.0.tgz", + "integrity": "sha512-2M4d5MufVXwi6VzZhJ9f5S/wU4ud2ck0kxPof1Iz3zWx6Y+V2eJrES9jEktB6O3o/oEyk+il/uNu9PvASjWXQw==", + "dev": true, + "requires": { + "@jest/types": "^24.8.0", + "jest-message-util": "^24.8.0", + "jest-mock": "^24.8.0" + } + }, + "@jest/test-result": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-24.8.0.tgz", + "integrity": "sha512-+YdLlxwizlfqkFDh7Mc7ONPQAhA4YylU1s529vVM1rsf67vGZH/2GGm5uO8QzPeVyaVMobCQ7FTxl38QrKRlng==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/types": "^24.8.0", + "@types/istanbul-lib-coverage": "^2.0.0" + } + }, + "@jest/transform": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-24.8.0.tgz", + "integrity": "sha512-xBMfFUP7TortCs0O+Xtez2W7Zu1PLH9bvJgtraN1CDST6LBM/eTOZ9SfwS/lvV8yOfcDpFmwf9bq5cYbXvqsvA==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^24.8.0", + "babel-plugin-istanbul": "^5.1.0", + "chalk": "^2.0.1", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.1.15", + "jest-haste-map": "^24.8.0", + "jest-regex-util": "^24.3.0", + "jest-util": "^24.8.0", + "micromatch": "^3.1.10", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "2.4.1" + } + }, + "@jest/types": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.8.0.tgz", + "integrity": "sha512-g17UxVr2YfBtaMUxn9u/4+siG1ptg9IGYAYwvpwn61nBg779RXnjE/m7CxYcIzEt0AbHZZAHSEZNhkE2WxURVg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^12.0.9" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", + "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "jest-haste-map": { + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-24.8.1.tgz", + "integrity": "sha512-SwaxMGVdAZk3ernAx2Uv2sorA7jm3Kx+lR0grp6rMmnY06Kn/urtKx1LPN2mGTea4fCT38impYT28FfcLUhX0g==", + "dev": true, + "requires": { + "@jest/types": "^24.8.0", + "anymatch": "^2.0.0", + "fb-watchman": "^2.0.0", + "fsevents": "^1.2.7", + "graceful-fs": "^4.1.15", + "invariant": "^2.2.4", + "jest-serializer": "^24.4.0", + "jest-util": "^24.8.0", + "jest-worker": "^24.6.0", + "micromatch": "^3.1.10", + "sane": "^4.0.3", + "walker": "^1.0.7" + } + }, + "jest-message-util": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.8.0.tgz", + "integrity": "sha512-p2k71rf/b6ns8btdB0uVdljWo9h0ovpnEe05ZKWceQGfXYr4KkzgKo3PBi8wdnd9OtNh46VpNIJynUn/3MKm1g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^24.8.0", + "@jest/types": "^24.8.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^2.0.1", + "micromatch": "^3.1.10", + "slash": "^2.0.0", + "stack-utils": "^1.0.1" + } + }, + "jest-mock": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-24.8.0.tgz", + "integrity": "sha512-6kWugwjGjJw+ZkK4mDa0Df3sDlUTsV47MSrT0nGQ0RBWJbpODDQ8MHDVtGtUYBne3IwZUhtB7elxHspU79WH3A==", + "dev": true, + "requires": { + "@jest/types": "^24.8.0" + } + }, + "jest-util": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-24.8.0.tgz", + "integrity": "sha512-DYZeE+XyAnbNt0BG1OQqKy/4GVLPtzwGx5tsnDrFcax36rVE3lTA5fbvgmbVPUZf9w77AJ8otqR4VBbfFJkUZA==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/fake-timers": "^24.8.0", + "@jest/source-map": "^24.3.0", + "@jest/test-result": "^24.8.0", + "@jest/types": "^24.8.0", + "callsites": "^3.0.0", + "chalk": "^2.0.1", + "graceful-fs": "^4.1.15", + "is-ci": "^2.0.0", + "mkdirp": "^0.5.1", + "slash": "^2.0.0", + "source-map": "^0.6.0" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "write-file-atomic": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.1.tgz", + "integrity": "sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + } + } + }, + "babel-loader": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.0.5.tgz", + "integrity": "sha512-NTnHnVRd2JnRqPC0vW+iOQWU5pchDbYXsG2E6DMXEpMfUcQKclF9gmf3G3ZMhzG7IG9ji4coL0cm+FxeWxDpnw==", + "dev": true, + "requires": { + "find-cache-dir": "^2.0.0", + "loader-utils": "^1.0.2", + "mkdirp": "^0.5.1", + "util.promisify": "^1.0.0" + }, + "dependencies": { + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "babel-plugin-inline-json-import": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/babel-plugin-inline-json-import/-/babel-plugin-inline-json-import-0.3.2.tgz", + "integrity": "sha512-QNNJx08KjmMT25Cw7rAPQ6dlREDPiZGDyApHL8KQ9vrQHbrr4PTi7W8g1tMMZPz0jEMd39nx/eH7xjnDNxq5sA==", + "dev": true, + "requires": { + "decache": "^4.5.1" + } + }, + "babel-plugin-istanbul": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.1.3.tgz", + "integrity": "sha512-IFyehbvRRwdBlI1lDp+FaMsWNnEndEk7065IB8NhzBX+ZKLPwPodgk4I5Gobw/8SNUUzso2Dv3hbqRh88eiSCQ==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "istanbul-lib-instrument": "^3.2.0", + "test-exclude": "^5.2.2" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "istanbul-lib-coverage": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", + "integrity": "sha512-LXTBICkMARVgo579kWDm8SqfB6nvSDKNqIOBEjmJRnL04JvoMHCYGWaMddQnseJYtkEuEvO/sIcOxPLk9gERug==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.2.0.tgz", + "integrity": "sha512-06IM3xShbNW4NgZv5AP4QH0oHqf1/ivFo8eFys0ZjPXHGldHJQWb3riYOKXqmOqfxXBfxu4B+g/iuhOPZH0RJg==", + "dev": true, + "requires": { + "@babel/generator": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "istanbul-lib-coverage": "^2.0.4", + "semver": "^6.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "semver": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", + "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "dev": true + } + } + }, + "babel-plugin-jest-hoist": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.6.0.tgz", + "integrity": "sha512-3pKNH6hMt9SbOv0F3WVmy5CWQ4uogS3k0GY5XLyQHJ9EGpAT9XWkFd2ZiXXtkwFHdAHa5j7w7kfxSP5lAIwu7w==", + "dev": true, + "requires": { + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-plugin-react-native-classname-to-style": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-classname-to-style/-/babel-plugin-react-native-classname-to-style-1.2.2.tgz", + "integrity": "sha512-kavZRZ56YoYmrwuCRMY0UuCYfMPszoN7v0DLST1D+rTqkl+cFWVW3W8l//AfWsyMNHkY+UTX+RrB5K7GY9mq3w==", + "dev": true + }, + "babel-plugin-react-native-platform-specific-extensions": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-platform-specific-extensions/-/babel-plugin-react-native-platform-specific-extensions-1.1.1.tgz", + "integrity": "sha512-ZNN2ImlUlcaPMfzaCw9fWmTc/Ht5ARI0n9RUqiyCdK7LalPJ7yKc+F60BcSHvMHcmA2BgVO8TpSot+dpeZPAMQ==", + "dev": true, + "requires": { + "@babel/template": "^7.0.0-beta.49" + } + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "7.0.0-beta.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz", + "integrity": "sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==", + "dev": true + }, + "babel-preset-fbjs": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-fbjs/-/babel-preset-fbjs-3.2.0.tgz", + "integrity": "sha512-5Jo+JeWiVz2wHUUyAlvb/sSYnXNig9r+HqGAOSfh5Fzxp7SnAaR/tEGRJ1ZX7C77kfk82658w6R5Z+uPATTD9g==", + "dev": true, + "requires": { + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-syntax-class-properties": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.0.0", + "@babel/plugin-transform-arrow-functions": "^7.0.0", + "@babel/plugin-transform-block-scoped-functions": "^7.0.0", + "@babel/plugin-transform-block-scoping": "^7.0.0", + "@babel/plugin-transform-classes": "^7.0.0", + "@babel/plugin-transform-computed-properties": "^7.0.0", + "@babel/plugin-transform-destructuring": "^7.0.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0", + "@babel/plugin-transform-for-of": "^7.0.0", + "@babel/plugin-transform-function-name": "^7.0.0", + "@babel/plugin-transform-literals": "^7.0.0", + "@babel/plugin-transform-member-expression-literals": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/plugin-transform-object-super": "^7.0.0", + "@babel/plugin-transform-parameters": "^7.0.0", + "@babel/plugin-transform-property-literals": "^7.0.0", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0", + "@babel/plugin-transform-spread": "^7.0.0", + "@babel/plugin-transform-template-literals": "^7.0.0", + "babel-plugin-syntax-trailing-function-commas": "^7.0.0-beta.0" + } + }, + "babel-preset-jest": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-24.6.0.tgz", + "integrity": "sha512-pdZqLEdmy1ZK5kyRUfvBb2IfTPb2BUvIJczlPspS8fWmBQslNNDBqVfh7BW5leOVJMDZKzjD8XEyABTk6gQ5yw==", + "dev": true, + "requires": { + "@babel/plugin-syntax-object-rest-spread": "^7.0.0", + "babel-plugin-jest-hoist": "^24.6.0" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz", + "integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + } + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + }, + "dependencies": { + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + } + } + }, + "babylon-walk": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babylon-walk/-/babylon-walk-1.0.2.tgz", + "integrity": "sha1-OxWl3btIKni0zpwByLoYFwLZ1s4=", + "dev": true, + "requires": { + "babel-runtime": "^6.11.6", + "babel-types": "^6.15.0", + "lodash.clone": "^4.5.0" + } + }, + "bail": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz", + "integrity": "sha512-1X8CnjFVQ+a+KW36uBNMTU5s8+v5FzeqrP7hTG5aTb4aPreSbZJlhwPon9VKMuEVgV++JM+SQrALY3kr7eswdg==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "dev": true + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "requires": { + "safe-buffer": "5.1.2" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "before-after-hook": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-1.4.0.tgz", + "integrity": "sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg==", + "dev": true + }, + "benchmark": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz", + "integrity": "sha1-CfPeMckWQl1JjMLuVloOvzwqVik=", + "dev": true, + "requires": { + "lodash": "^4.17.4", + "platform": "^1.3.3" + } + }, + "bfj": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-6.1.1.tgz", + "integrity": "sha512-+GUNvzHR4nRyGybQc2WpNJL4MJazMuvf92ueIyA0bIkPRwhhQu3IfZQ2PSoVPpCBJfmoSdOxu5rnotfFLlvYRQ==", + "dev": true, + "requires": { + "bluebird": "^3.5.1", + "check-types": "^7.3.0", + "hoopy": "^0.1.2", + "tryer": "^1.0.0" + } + }, + "big-integer": { + "version": "1.6.44", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.44.tgz", + "integrity": "sha512-7MzElZPTyJ2fNvBkPxtFQ2fWIkVmuzw41+BZHSzpEq3ymB2MfeKp1+yXl/tS75xCx+WnyV+yb0kp+K1C3UNwmQ==", + "dev": true + }, + "big.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", + "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==", + "dev": true + }, + "binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=", + "dev": true, + "requires": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + } + }, + "binary-extensions": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.0.tgz", + "integrity": "sha512-EgmjVLMn22z7eGGv3kcnHwSnJXmFHjISTY9E/S5lIcTD3Oxw05QTcBLNkJFzcb3cNueUdF/IN4U+d78V0zO8Hw==", + "dev": true + }, + "bindings": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", + "integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE=", + "dev": true + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha512-OorbnJVPII4DuUKbjARAe8u8EfqOmkEEaSFIyoQ7OjTHn6kafxWl0wLgoZ2rXaYd7MyLcDaU4TmhfxtwgcccMQ==", + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==", + "dev": true + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, + "body": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/body/-/body-5.1.0.tgz", + "integrity": "sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk=", + "dev": true, + "requires": { + "continuable-cache": "^0.3.1", + "error": "^7.0.0", + "raw-body": "~1.1.0", + "safe-json-parse": "~1.0.1" + } + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.1", + "http-errors": "~1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "~2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "~1.6.15" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", + "dev": true + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", + "dev": true + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + }, + "dependencies": { + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=", + "dev": true + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "dev": true, + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + } + } + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=", + "dev": true + } + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "bplist-creator": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz", + "integrity": "sha1-N98VNgkoJLh8QvlXsBNEEXNyrkU=", + "dev": true, + "requires": { + "stream-buffers": "~2.2.0" + } + }, + "bplist-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz", + "integrity": "sha1-1g1dzCDLptx+HymbNdPh+V2vuuY=", + "dev": true, + "requires": { + "big-integer": "^1.6.7" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brcast": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brcast/-/brcast-2.0.2.tgz", + "integrity": "sha512-Tfn5JSE7hrUlFcOoaLzVvkbgIemIorMIyoMr3TgvszWW7jFt2C9PdeMLtysYD9RU0MmU17b69+XJG1eRY2OBRg==" + }, + "brfs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/brfs/-/brfs-1.6.1.tgz", + "integrity": "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ==", + "dev": true, + "requires": { + "quote-stream": "^1.0.1", + "resolve": "^1.1.5", + "static-module": "^2.2.0", + "through2": "^2.0.0" + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browser-process-hrtime": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.2.tgz", + "integrity": "sha1-Ql1opY00R/AqBKqJQYf86K+Le44=", + "dev": true + }, + "browser-resolve": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", + "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", + "dev": true, + "requires": { + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + } + } + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.7.0.tgz", + "integrity": "sha512-9rGNDtnj+HaahxiVV38Gn8n8Lr8REKsel68v1sPFfIGEK6uSXTY3h9acgiT1dZVtOOUtifo/Dn8daDQ5dUgVsA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000989", + "electron-to-chromium": "^1.3.247", + "node-releases": "^1.1.29" + }, + "dependencies": { + "caniuse-lite": { + "version": "1.0.30000999", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000999.tgz", + "integrity": "sha512-1CUyKyecPeksKwXZvYw0tEoaMCo/RwBlXmEtN5vVnabvO0KPd9RQLcaAuR9/1F+KDMv6esmOFWlsXuzDk+8rxg==", + "dev": true + }, + "node-releases": { + "version": "1.1.35", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.35.tgz", + "integrity": "sha512-JGcM/wndCN/2elJlU0IGdVEJQQnJwsLbgPCFd2pY7V0mxf17bZ0Gb/lgOtL29ZQhvEX5shnVhxQyZz3ex94N8w==", + "dev": true, + "requires": { + "semver": "^6.3.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "bser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.0.0.tgz", + "integrity": "sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk=", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha1-M3dm2hWAEhD92VbCLpxokaudAzc=", + "dev": true + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, + "buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=", + "dev": true + }, + "buffer-from": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", + "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=", + "dev": true + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha1-y5T662HIaWRR2zZTThQi+U8K7og=", + "dev": true + }, + "byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE=", + "dev": true + }, + "byte-size": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-5.0.1.tgz", + "integrity": "sha512-/XuKeqWocKsYa/cBY1YbSJSWWqTi4cFgr9S6OyM7PBaPbr9zvNGwWP33vt0uqGhwDdN+y3yhbXVILEUpnwEWGw==", + "dev": true + }, + "bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz", + "integrity": "sha1-NWnt6Lo0MV+rmcPpLLBMciDeH6g=", + "dev": true + }, + "cacache": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", + "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", + "dev": true, + "requires": { + "bluebird": "^3.5.1", + "chownr": "^1.0.1", + "glob": "^7.1.2", + "graceful-fs": "^4.1.11", + "lru-cache": "^4.1.1", + "mississippi": "^2.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.2", + "ssri": "^5.2.4", + "unique-filename": "^1.1.0", + "y18n": "^4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + } + } + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "dev": true, + "requires": { + "callsites": "^2.0.0" + }, + "dependencies": { + "callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=", + "dev": true + } + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "dev": true, + "requires": { + "caller-callsite": "^2.0.0" + } + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", + "dev": true + }, + "callsites": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.0.0.tgz", + "integrity": "sha512-tWnkwu9YEq2uzlBDI4RcLn8jrFvF9AOi8PxDNU3hZZjJcjkcRAq3vCI+vZcg1SuxISDYe86k9VZFwAxDiJGoAw==", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=" + }, + "camelcase-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", + "integrity": "sha1-oqpfsa9oh1glnDLBQUJteJI7m3c=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "map-obj": "^2.0.0", + "quick-lru": "^1.0.0" + } + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30000957", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000957.tgz", + "integrity": "sha512-8wxNrjAzyiHcLXN/iunskqQnJquQQ6VX8JHfW5kLgAPRSiSuKZiNfmIkP5j7jgyXqAQBSoXyJxfnbCFS0ThSiQ==", + "dev": true + }, + "capture-exit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", + "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "dev": true, + "requires": { + "rsvp": "^4.8.4" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "ccount": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.0.3.tgz", + "integrity": "sha512-Jt9tIBkRc9POUof7QA/VwWd+58fKkEEfI+/t1/eOlxKM7ZhrczNzMFefge7Ai+39y1pR/pP6cI19guHy3FSLmw==", + "dev": true + }, + "chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=", + "dev": true, + "requires": { + "traverse": ">=0.3.0 <0.4" + }, + "dependencies": { + "traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=", + "dev": true + } + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "character-entities": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.2.tgz", + "integrity": "sha512-sMoHX6/nBiy3KKfC78dnEalnpn0Az0oSNvqUWYTtYrhRI5iUIYsROU48G+E+kMFQzqXaJ8kHJZ85n7y6/PHgwQ==", + "dev": true + }, + "character-entities-html4": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-1.1.2.tgz", + "integrity": "sha512-sIrXwyna2+5b0eB9W149izTPJk/KkJTg6mEzDGibwBUkyH1SbDa+nf515Ppdi3MaH35lW0JFJDWeq9Luzes1Iw==", + "dev": true + }, + "character-entities-legacy": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz", + "integrity": "sha512-9NB2VbXtXYWdXzqrvAHykE/f0QJxzaKIpZ5QzNZrrgQ7Iyxr2vnfS8fCBNVW9nUEZE0lo57nxKRqnzY/dKrwlA==", + "dev": true + }, + "character-reference-invalid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz", + "integrity": "sha512-7I/xceXfKyUJmSAn/jw8ve/9DyOP7XxufNYLI9Px7CmsKgEUaZLUTax6nZxGQtaoiZCjpu6cHPj20xC/vqRReQ==", + "dev": true + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "check-node-version": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/check-node-version/-/check-node-version-3.2.0.tgz", + "integrity": "sha512-mJu4dADRf+NUeOyGgFTXaLtjyyffD3Eej2RA9IEk1CdHmoVurErLD++e/Ps6uKfsB273ky+0Z9NlOiuplxuNdw==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "map-values": "^1.0.1", + "minimist": "^1.2.0", + "object-filter": "^1.0.2", + "object.assign": "^4.0.4", + "run-parallel": "^1.1.4", + "semver": "^5.0.3" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "check-types": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-7.4.0.tgz", + "integrity": "sha512-YbulWHdfP99UfZ73NcUDlNJhEIDgm9Doq9GhpyXbF+7Aegi3CVV7qqMCKTTqJxlvEvnQBp9IA+dxsGN6xK/nSg==", + "dev": true + }, + "cheerio": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz", + "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=", + "dev": true, + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash": "^4.15.0", + "parse5": "^3.0.1" + } + }, + "chokidar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.1.tgz", + "integrity": "sha512-gfw3p2oQV2wEt+8VuMlNsPjCxDxvvgnm/kz+uATu805mWVF8IJN7uz9DN7iBz+RMJISmiVbCOBFs9qBGMjtPfQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.0" + }, + "dependencies": { + "fsevents": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.3.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + } + } + }, + "chownr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz", + "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", + "dev": true + }, + "chrome-trace-event": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "classnames": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", + "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-spinners": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.3.1.tgz", + "integrity": "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg==", + "dev": true + }, + "cli-table3": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.5.1.tgz", + "integrity": "sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==", + "dev": true, + "requires": { + "colors": "^1.1.2", + "object-assign": "^4.1.0", + "string-width": "^2.1.1" + }, + "dependencies": { + "colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.3.tgz", + "integrity": "sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==", + "dev": true, + "optional": true + } + } + }, + "cli-truncate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", + "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "dev": true, + "requires": { + "slice-ansi": "0.0.4", + "string-width": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "clipboard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.1.tgz", + "integrity": "sha512-7yhQBmtN+uYZmfRjjVjKa0dZdWuabzpSKGtyQZN+9C8xlC788SSJjOHWh7tzurfwTqTD5UDYAhIv5fRJg3sHjQ==", + "requires": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "clone-deep": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-2.0.2.tgz", + "integrity": "sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.0", + "shallow-clone": "^1.0.0" + }, + "dependencies": { + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + } + } + }, + "clone-regexp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.1.tgz", + "integrity": "sha512-Fcij9IwRW27XedRIJnSOEupS7RVcXtObJXbcUOX93UCLqqOdRpkvzKywOOSizmEK/Is3S/RHX9dLdfo6R1Q1mw==", + "dev": true, + "requires": { + "is-regexp": "^1.0.0", + "is-supported-regexp-flag": "^1.0.0" + } + }, + "clones": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/clones/-/clones-1.2.0.tgz", + "integrity": "sha512-FXDYw4TjR8wgPZYui2LeTqWh1BLpfQ8lB6upMtlpDF6WlOOxghmTTxWyngdKTgozqBgKnHbTVwTE+hOHqAykuQ==", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "requires": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "collapse-white-space": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.4.tgz", + "integrity": "sha512-YfQ1tAUZm561vpYD+5eyWN8+UsceQbSrqqlc/6zDY2gtAE+uZLSdkkovhnGpmCThsvKBFakq4EdY/FF93E8XIw==", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/color/-/color-0.11.4.tgz", + "integrity": "sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q=", + "dev": true, + "requires": { + "clone": "^1.0.2", + "color-convert": "^1.3.0", + "color-string": "^0.3.0" + } + }, + "color-convert": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", + "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", + "requires": { + "color-name": "1.1.1" + } + }, + "color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=" + }, + "color-string": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz", + "integrity": "sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=", + "dev": true, + "requires": { + "color-name": "^1.0.0" + } + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "colorette": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.1.0.tgz", + "integrity": "sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg==", + "dev": true + }, + "colors": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=", + "dev": true + }, + "columnify": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.5.4.tgz", + "integrity": "sha1-Rzfd8ce2mop8NAVweC6UfuyOeLs=", + "dev": true, + "requires": { + "strip-ansi": "^3.0.0", + "wcwidth": "^1.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "command-exists": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.8.tgz", + "integrity": "sha512-PM54PkseWbiiD/mMsbvW351/u+dafwTJ0ye2qB60G1aGQP9j3xK2gmMDc+R34L3nDtx4qMCitXT75mkbkGJDLw==", + "dev": true + }, + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "dev": true + }, + "comment-parser": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.6.1.tgz", + "integrity": "sha512-Putzd7Ilyvknmb1KxGf5el9uw0sPx9gEVnDrm8tlvXGN1i8Uaa2VBxB32hUhfzTlrEhhxNQ+pKq4ZNe8wNxjmw==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "compare-func": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-1.3.2.tgz", + "integrity": "sha1-md0LpFfh+bxyKxLAjsM+6rMfpkg=", + "dev": true, + "requires": { + "array-ify": "^1.0.0", + "dot-prop": "^3.0.0" + }, + "dependencies": { + "dot-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-3.0.0.tgz", + "integrity": "sha1-G3CK8JSknJoOfbyteQq6U52sEXc=", + "dev": true, + "requires": { + "is-obj": "^1.0.0" + } + } + } + }, + "compare-versions": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.4.0.tgz", + "integrity": "sha512-tK69D7oNXXqUW3ZNo/z7NXTEz22TCF0pTE+YF9cxvaAM9XnkLo1fV621xCLrRR6aevJlKxExkss0vWqUCUpqdg==", + "dev": true + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "compressible": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz", + "integrity": "sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw==", + "dev": true, + "requires": { + "mime-db": ">= 1.40.0 < 2" + }, + "dependencies": { + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + } + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "computed-style": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz", + "integrity": "sha1-fzRP2FhLLkJb7cpKGvwOMAuwXXQ=" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "concurrently": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-3.5.0.tgz", + "integrity": "sha1-jPG3cHppFqeKT/W3e7BN7FSzebI=", + "dev": true, + "requires": { + "chalk": "0.5.1", + "commander": "2.6.0", + "date-fns": "^1.23.0", + "lodash": "^4.5.1", + "rx": "2.3.24", + "spawn-command": "^0.0.2-1", + "supports-color": "^3.2.3", + "tree-kill": "^1.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz", + "integrity": "sha1-DY6UaWej2BQ/k+JOKYUl/BsiNfk=", + "dev": true + }, + "ansi-styles": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz", + "integrity": "sha1-6uy/Zs1waIJ2Cy9GkVgrj1XXp94=", + "dev": true + }, + "chalk": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz", + "integrity": "sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ=", + "dev": true, + "requires": { + "ansi-styles": "^1.1.0", + "escape-string-regexp": "^1.0.0", + "has-ansi": "^0.1.0", + "strip-ansi": "^0.3.0", + "supports-color": "^0.2.0" + }, + "dependencies": { + "supports-color": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz", + "integrity": "sha1-2S3iaU6z9nMjlz1649i1W0wiGQo=", + "dev": true + } + } + }, + "commander": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.6.0.tgz", + "integrity": "sha1-nfflL7Kgyw+4kFjugMMQQiXzfh0=", + "dev": true + }, + "has-ansi": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz", + "integrity": "sha1-hPJlqujA5qiKEtcCKJS3VoiUxi4=", + "dev": true, + "requires": { + "ansi-regex": "^0.2.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "strip-ansi": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz", + "integrity": "sha1-JfSOoiynkYfzF0pNuHWTR7sSYiA=", + "dev": true, + "requires": { + "ansi-regex": "^0.2.1" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", + "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "dev": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true + } + } + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "consolidated-events": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/consolidated-events/-/consolidated-events-2.0.2.tgz", + "integrity": "sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ==" + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=", + "dev": true + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "continuable-cache": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", + "integrity": "sha1-vXJ6f67XfnH/OYWskzUakSczrQ8=", + "dev": true + }, + "conventional-changelog-angular": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.3.tgz", + "integrity": "sha512-YD1xzH7r9yXQte/HF9JBuEDfvjxxwDGGwZU1+ndanbY0oFgA+Po1T9JDSpPLdP0pZT6MhCAsdvFKC4TJ4MTJTA==", + "dev": true, + "requires": { + "compare-func": "^1.3.1", + "q": "^1.5.1" + } + }, + "conventional-changelog-core": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-3.2.3.tgz", + "integrity": "sha512-LMMX1JlxPIq/Ez5aYAYS5CpuwbOk6QFp8O4HLAcZxe3vxoCtABkhfjetk8IYdRB9CDQGwJFLR3Dr55Za6XKgUQ==", + "dev": true, + "requires": { + "conventional-changelog-writer": "^4.0.6", + "conventional-commits-parser": "^3.0.3", + "dateformat": "^3.0.0", + "get-pkg-repo": "^1.0.0", + "git-raw-commits": "2.0.0", + "git-remote-origin-url": "^2.0.0", + "git-semver-tags": "^2.0.3", + "lodash": "^4.2.1", + "normalize-package-data": "^2.3.5", + "q": "^1.5.1", + "read-pkg": "^3.0.0", + "read-pkg-up": "^3.0.0", + "through2": "^3.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "dev": true, + "requires": { + "readable-stream": "2 || 3" + } + } + } + }, + "conventional-changelog-preset-loader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-2.2.0.tgz", + "integrity": "sha512-zXB+5vF7D5Y3Cb/rJfSyCCvFphCVmF8mFqOdncX3BmjZwAtGAPfYrBcT225udilCKvBbHgyzgxqz2GWDB5xShQ==", + "dev": true + }, + "conventional-changelog-writer": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-4.0.7.tgz", + "integrity": "sha512-p/wzs9eYaxhFbrmX/mCJNwJuvvHR+j4Fd0SQa2xyAhYed6KBiZ780LvoqUUvsayP4R1DtC27czalGUhKV2oabw==", + "dev": true, + "requires": { + "compare-func": "^1.3.1", + "conventional-commits-filter": "^2.0.2", + "dateformat": "^3.0.0", + "handlebars": "^4.1.2", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.2.1", + "meow": "^4.0.0", + "semver": "^6.0.0", + "split": "^1.0.0", + "through2": "^3.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "dev": true, + "requires": { + "readable-stream": "2 || 3" + } + } + } + }, + "conventional-commits-filter": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-2.0.2.tgz", + "integrity": "sha512-WpGKsMeXfs21m1zIw4s9H5sys2+9JccTzpN6toXtxhpw2VNF2JUXwIakthKBy+LN4DvJm+TzWhxOMWOs1OFCFQ==", + "dev": true, + "requires": { + "lodash.ismatch": "^4.4.0", + "modify-values": "^1.0.0" + } + }, + "conventional-commits-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.0.3.tgz", + "integrity": "sha512-KaA/2EeUkO4bKjinNfGUyqPTX/6w9JGshuQRik4r/wJz7rUw3+D3fDG6sZSEqJvKILzKXFQuFkpPLclcsAuZcg==", + "dev": true, + "requires": { + "JSONStream": "^1.0.4", + "is-text-path": "^2.0.0", + "lodash": "^4.2.1", + "meow": "^4.0.0", + "split2": "^2.0.0", + "through2": "^3.0.0", + "trim-off-newlines": "^1.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "dev": true, + "requires": { + "readable-stream": "2 || 3" + } + } + } + }, + "conventional-recommended-bump": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-5.0.1.tgz", + "integrity": "sha512-RVdt0elRcCxL90IrNP0fYCpq1uGt2MALko0eyeQ+zQuDVWtMGAy9ng6yYn3kax42lCj9+XBxQ8ZN6S9bdKxDhQ==", + "dev": true, + "requires": { + "concat-stream": "^2.0.0", + "conventional-changelog-preset-loader": "^2.1.1", + "conventional-commits-filter": "^2.0.2", + "conventional-commits-parser": "^3.0.3", + "git-raw-commits": "2.0.0", + "git-semver-tags": "^2.0.3", + "meow": "^4.0.0", + "q": "^1.5.1" + }, + "dependencies": { + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "convert-source-map": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", + "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=", + "dev": true + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", + "dev": true + }, + "copy-concurrently": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", + "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "copy-webpack-plugin": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz", + "integrity": "sha512-zmC33E8FFSq3AbflTvqvPvBo621H36Afsxlui91d+QyZxPIuXghfnTsa1CuqiAaCPgJoSUWfTFbKJnadZpKEbQ==", + "dev": true, + "requires": { + "cacache": "^10.0.4", + "find-cache-dir": "^1.0.0", + "globby": "^7.1.1", + "is-glob": "^4.0.0", + "loader-utils": "^1.1.0", + "minimatch": "^3.0.4", + "p-limit": "^1.0.0", + "serialize-javascript": "^1.4.0" + }, + "dependencies": { + "globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "core-js": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.1.4.tgz", + "integrity": "sha512-YNZN8lt82XIMLnLirj9MhKDFZHalwzzrL9YLt6eb0T5D0EDl4IQ90IGkua8mHbnxNrkj1d8hbdizMc0Qmg1WnQ==", + "dev": true + }, + "core-js-compat": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.1.4.tgz", + "integrity": "sha512-Z5zbO9f1d0YrJdoaQhphVAnKPimX92D6z8lCGphH89MNRxlL1prI9ExJPqVwP0/kgkQCv8c4GJGT8X16yUncOg==", + "dev": true, + "requires": { + "browserslist": "^4.6.2", + "core-js-pure": "3.1.4", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.1.2.tgz", + "integrity": "sha512-z4PqiCpomGtWj8633oeAdXm1Kn1W++3T8epkZYnwiVgIYIJ0QHszhInYSJTYxebByQH7KVCEAn8R9duzZW2PhQ==", + "dev": true + } + } + }, + "core-js-pure": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.1.4.tgz", + "integrity": "sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cosmiconfig": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.0.5.tgz", + "integrity": "sha512-94j37OtvxS5w7qr7Ta6dt67tWdnOxigBVN4VnSxNXFez9o18PGQ0D33SchKP17r9LAcWVTYV72G6vDayAUBFIg==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.9.0", + "parse-json": "^4.0.0" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + } + } + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "create-react-class": { + "version": "15.6.3", + "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz", + "integrity": "sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==", + "dev": true, + "requires": { + "fbjs": "^0.8.9", + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + }, + "cross-env": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-3.2.4.tgz", + "integrity": "sha1-ngWF8neGTtQhznVvgamA/w1piro=", + "dev": true, + "requires": { + "cross-spawn": "^5.1.0", + "is-windows": "^1.0.0" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + } + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "css-color-function": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/css-color-function/-/css-color-function-1.3.3.tgz", + "integrity": "sha1-jtJMLAIFBzM5+voAS8jBQfzLKC4=", + "dev": true, + "requires": { + "balanced-match": "0.1.0", + "color": "^0.11.0", + "debug": "^3.1.0", + "rgb": "~0.1.0" + }, + "dependencies": { + "balanced-match": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz", + "integrity": "sha1-tQS9BYabOSWd0MXvw12EMXbczEo=", + "dev": true + } + } + }, + "css-color-names": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", + "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=", + "dev": true + }, + "css-declaration-sorter": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz", + "integrity": "sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA==", + "dev": true, + "requires": { + "postcss": "^7.0.1", + "timsort": "^0.3.0" + } + }, + "css-modules-loader-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/css-modules-loader-core/-/css-modules-loader-core-1.1.0.tgz", + "integrity": "sha1-WQhmgpShvs0mGuCkziGwtVHyHRY=", + "dev": true, + "requires": { + "icss-replace-symbols": "1.1.0", + "postcss": "6.0.1", + "postcss-modules-extract-imports": "1.1.0", + "postcss-modules-local-by-default": "1.2.0", + "postcss-modules-scope": "1.1.0", + "postcss-modules-values": "1.3.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "dependencies": { + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "postcss": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.1.tgz", + "integrity": "sha1-AA29H47vIXqjaLmiEsX8QLKo8/I=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "source-map": "^0.5.6", + "supports-color": "^3.2.3" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "dev": true, + "requires": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true + }, + "css-selector-tokenizer": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.1.tgz", + "integrity": "sha512-xYL0AMZJ4gFzJQsHUKa5jiWWi2vH77WVNg7JYRyewwj6oPh4yb/y6Y9ZCw9dsj/9UauMhtuxR+ogQd//EdEVNA==", + "dev": true, + "requires": { + "cssesc": "^0.1.0", + "fastparse": "^1.1.1", + "regexpu-core": "^1.0.0" + }, + "dependencies": { + "cssesc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz", + "integrity": "sha1-yBSQPkViM3GgR3tAEJqq++6t27Q=", + "dev": true + }, + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + }, + "regexpu-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz", + "integrity": "sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs=", + "dev": true, + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + } + } + } + }, + "css-tree": { + "version": "1.0.0-alpha.28", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.28.tgz", + "integrity": "sha512-joNNW1gCp3qFFzj4St6zk+Wh/NBv0vM5YbEreZk0SD4S23S+1xBKb6cLDg2uj4P4k/GUMlIm6cKIDqIG+vdt0w==", + "dev": true, + "requires": { + "mdn-data": "~1.1.0", + "source-map": "^0.5.3" + } + }, + "css-unit-converter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.1.tgz", + "integrity": "sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY=", + "dev": true + }, + "css-url-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/css-url-regex/-/css-url-regex-1.1.0.tgz", + "integrity": "sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w=", + "dev": true + }, + "css-what": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz", + "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=", + "dev": true + }, + "cssesc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-1.0.1.tgz", + "integrity": "sha512-S2hzrpWvE6G/rW7i7IxJfWBYn27QWfOIncUW++8Rbo1VB5zsJDSVPcnI+Q8z7rhxT6/yZeLOCja4cZnghJrNGA==", + "dev": true + }, + "cssnano": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz", + "integrity": "sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "cssnano-preset-default": "^4.0.7", + "is-resolvable": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "cssnano-preset-default": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz", + "integrity": "sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==", + "dev": true, + "requires": { + "css-declaration-sorter": "^4.0.1", + "cssnano-util-raw-cache": "^4.0.1", + "postcss": "^7.0.0", + "postcss-calc": "^7.0.1", + "postcss-colormin": "^4.0.3", + "postcss-convert-values": "^4.0.1", + "postcss-discard-comments": "^4.0.2", + "postcss-discard-duplicates": "^4.0.2", + "postcss-discard-empty": "^4.0.1", + "postcss-discard-overridden": "^4.0.1", + "postcss-merge-longhand": "^4.0.11", + "postcss-merge-rules": "^4.0.3", + "postcss-minify-font-values": "^4.0.2", + "postcss-minify-gradients": "^4.0.2", + "postcss-minify-params": "^4.0.2", + "postcss-minify-selectors": "^4.0.2", + "postcss-normalize-charset": "^4.0.1", + "postcss-normalize-display-values": "^4.0.2", + "postcss-normalize-positions": "^4.0.2", + "postcss-normalize-repeat-style": "^4.0.2", + "postcss-normalize-string": "^4.0.2", + "postcss-normalize-timing-functions": "^4.0.2", + "postcss-normalize-unicode": "^4.0.1", + "postcss-normalize-url": "^4.0.1", + "postcss-normalize-whitespace": "^4.0.2", + "postcss-ordered-values": "^4.1.2", + "postcss-reduce-initial": "^4.0.3", + "postcss-reduce-transforms": "^4.0.2", + "postcss-svgo": "^4.0.2", + "postcss-unique-selectors": "^4.0.1" + } + }, + "cssnano-util-get-arguments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz", + "integrity": "sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=", + "dev": true + }, + "cssnano-util-get-match": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz", + "integrity": "sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=", + "dev": true + }, + "cssnano-util-raw-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz", + "integrity": "sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "cssnano-util-same-parent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz", + "integrity": "sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==", + "dev": true + }, + "csso": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/csso/-/csso-3.5.1.tgz", + "integrity": "sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg==", + "dev": true, + "requires": { + "css-tree": "1.0.0-alpha.29" + }, + "dependencies": { + "css-tree": { + "version": "1.0.0-alpha.29", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.29.tgz", + "integrity": "sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg==", + "dev": true, + "requires": { + "mdn-data": "~1.1.0", + "source-map": "^0.5.3" + } + } + } + }, + "cssom": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.4.tgz", + "integrity": "sha512-+7prCSORpXNeR4/fUP3rL+TzqtiFfhMvTd7uEqMdgPvLPt4+uzFUeufx5RHjGTACCargg/DiEt/moMQmvnfkog==", + "dev": true + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "cwd": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/cwd/-/cwd-0.10.0.tgz", + "integrity": "sha1-FyQAaUBXwioTsM8WFix+S3p/5Wc=", + "dev": true, + "requires": { + "find-pkg": "^0.1.2", + "fs-exists-sync": "^0.1.0" + } + }, + "cyclist": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", + "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "dev": true + }, + "damerau-levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz", + "integrity": "sha512-CBCRqFnpu715iPmw1KrdOrzRqbdFwQTwAWyyyYS42+iAgHCuXZ+/TdMgQkUENPomxEz9z1BEzuQU2Xw0kUuAgA==", + "dev": true + }, + "dargs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-4.1.0.tgz", + "integrity": "sha1-A6nbtLXC8Tm/FK5T8LiipqhvThc=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-urls": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.0.0.tgz", + "integrity": "sha512-ai40PPQR0Fn1lD2PPie79CibnlMN2AYiDhwFX/rZHVsxbs5kNJSjegqXIprhouGXlRdEnfybva7kqRGnB6mypA==", + "dev": true, + "requires": { + "abab": "^1.0.4", + "whatwg-mimetype": "^2.0.0", + "whatwg-url": "^6.4.0" + } + }, + "date-fns": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz", + "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw==", + "dev": true + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "dev": true + }, + "deasync": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/deasync/-/deasync-0.1.14.tgz", + "integrity": "sha512-wN8sIuEqIwyQh72AG7oY6YQODCxIp1eXzEZlZznBuwDF8Q03Tdy9QNp1BNZXeadXoklNrw+Ip1fch+KXo/+ASw==", + "dev": true, + "requires": { + "bindings": "~1.2.1", + "node-addon-api": "^1.6.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "debuglog": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", + "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", + "dev": true + }, + "decache": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.5.1.tgz", + "integrity": "sha512-5J37nATc6FmOTLbcsr9qx7Nm28qQyg1SK4xyEHqM0IBkNhWFp0Sm+vKoWYHD8wq+OUEb9jLyaKFfzzd1A9hcoA==", + "dev": true, + "requires": { + "callsite": "^1.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "decamelize-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", + "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", + "dev": true, + "requires": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + } + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "decompress-zip": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decompress-zip/-/decompress-zip-0.2.2.tgz", + "integrity": "sha512-v+Na3Ck86Px7s2ix+f77pMQC3GlkxHHN+YyvnkEW7+xX5F39pcDpIV/VFvGYk8MznTFcMoPjL3XNWEJLXWoSPw==", + "dev": true, + "requires": { + "binary": "^0.3.0", + "graceful-fs": "^4.1.3", + "mkpath": "^0.1.0", + "nopt": "^3.0.1", + "q": "^1.1.2", + "readable-stream": "^1.1.8", + "touch": "0.0.3" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, + "deep-freeze": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz", + "integrity": "sha1-OgsABd4YZygZ39OM0x+RF5yJPoQ=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "deepmerge": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-1.5.2.tgz", + "integrity": "sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==" + }, + "default-require-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", + "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "dev": true, + "requires": { + "strip-bom": "^3.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + } + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "requires": { + "foreach": "^2.0.5", + "object-keys": "^1.0.8" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "del": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", + "integrity": "sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==", + "dev": true, + "requires": { + "globby": "^10.0.1", + "graceful-fs": "^4.2.2", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.1", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.2.tgz", + "integrity": "sha512-z8+wGWV2dgUhLqrtRYa03yDx4HWMvXKi1z8g3m2JyxAx8F7xk74asqPk5LAETjqDSGLFML/6CDl0+yFunSYicw==", + "dev": true + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "fast-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.0.4.tgz", + "integrity": "sha512-wkIbV6qg37xTJwqSsdnIphL1e+LaGz4AIQqr00mIubMaEhv1/HEmJ0uuCGZRNRUkZZmOB5mJKO0ZUTVq+SxMQg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.1", + "@nodelib/fs.walk": "^1.2.1", + "glob-parent": "^5.0.0", + "is-glob": "^4.0.1", + "merge2": "^1.2.3", + "micromatch": "^4.0.2" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", + "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globby": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", + "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", + "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", + "dev": true + }, + "ignore": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz", + "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-path-inside": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.1.tgz", + "integrity": "sha512-CKstxrctq1kUesU6WhtZDbYKzzYBuRH0UYInAVrkc/EYdB9ltbfE0gOoayG9nhohG6447sOOVGhHqsdmBvkbNg==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "rimraf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "denodeify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/denodeify/-/denodeify-1.2.1.tgz", + "integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "deprecation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.0.0.tgz", + "integrity": "sha512-lbQN037mB3VfA2JFuguM5GCJ+zPinMeCrFe+AfSZ6eqrnJA/Fs+EYMnd6Nb2mn9lf2jO9xwEd9o9lic+D4vkcw==", + "dev": true + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detect-indent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", + "integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50=", + "dev": true + }, + "detect-newline": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", + "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", + "dev": true + }, + "dezalgo": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", + "integrity": "sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=", + "dev": true, + "requires": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" + }, + "diff-sequences": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.3.0.tgz", + "integrity": "sha512-xLqpez+Zj9GKSnPWS0WZw1igGocZ+uua8+y+5dDNTT934N3QuY1sp2LkHzwiaYQGz60hMq0pjAshdeXm5VUOEw==", + "dev": true + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "dir-glob": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", + "integrity": "sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "path-type": "^3.0.0" + } + }, + "direction": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.2.tgz", + "integrity": "sha512-hSKoz5FBn+zhP9vWKkVQaaxnRDg3/MoPdcg2au54HIUDR8MrP8Ah1jXSJwCXel6SV3Afh5DSzc8Uqv2r1UoQwQ==" + }, + "discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", + "dev": true + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-scroll-into-view": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-1.2.1.tgz", + "integrity": "sha1-6PNnMt0ImwIBqI14Fdw/iObWbH4=" + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + } + } + }, + "dom-walk": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz", + "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=", + "dev": true + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "requires": { + "webidl-conversions": "^4.0.2" + } + }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot-prop": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", + "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", + "dev": true, + "requires": { + "is-obj": "^1.0.0" + } + }, + "dotenv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-5.0.1.tgz", + "integrity": "sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow==", + "dev": true + }, + "dotenv-expand": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-4.2.0.tgz", + "integrity": "sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU=", + "dev": true + }, + "duplexer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", + "dev": true + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, + "duplexify": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.6.0.tgz", + "integrity": "sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "editorconfig": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", + "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + }, + "dependencies": { + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "dev": true + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "ejs": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.6.1.tgz", + "integrity": "sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.282", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.282.tgz", + "integrity": "sha512-irSaDeCGgfMu1OA30bhqIBr+dx+pDJjRbwCpob7YWqVZbzXblybNzPGklVnWqv4EXxbkEAzQYqiNCqNTgu00lQ==", + "dev": true + }, + "elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", + "dev": true + }, + "elliptic": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", + "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "~0.4.13" + } + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "enhanced-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz", + "integrity": "sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.4.0", + "tapable": "^1.0.0" + } + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + }, + "env-paths": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-1.0.0.tgz", + "integrity": "sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA=", + "dev": true + }, + "envinfo": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.3.1.tgz", + "integrity": "sha512-GvXiDTqLYrORVSCuJCsWHPXF5BFvoWMQA9xX4YVjPT1jyS3aZEHUBwjzxU/6LTPF9ReHgVEbX7IEN5UvSXHw/A==", + "dev": true + }, + "enzyme": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.9.0.tgz", + "integrity": "sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg==", + "dev": true, + "requires": { + "array.prototype.flat": "^1.2.1", + "cheerio": "^1.0.0-rc.2", + "function.prototype.name": "^1.1.0", + "has": "^1.0.3", + "html-element-map": "^1.0.0", + "is-boolean-object": "^1.0.0", + "is-callable": "^1.1.4", + "is-number-object": "^1.0.3", + "is-regex": "^1.0.4", + "is-string": "^1.0.4", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.6.0", + "object-is": "^1.0.1", + "object.assign": "^4.1.0", + "object.entries": "^1.0.4", + "object.values": "^1.0.4", + "raf": "^3.4.0", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.1.2" + } + }, + "enzyme-adapter-react-16": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.14.0.tgz", + "integrity": "sha512-7PcOF7pb4hJUvjY7oAuPGpq3BmlCig3kxXGi2kFx0YzJHppqX1K8IIV9skT1IirxXlu8W7bneKi+oQ10QRnhcA==", + "dev": true, + "requires": { + "enzyme-adapter-utils": "^1.12.0", + "has": "^1.0.3", + "object.assign": "^4.1.0", + "object.values": "^1.1.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.6", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "dependencies": { + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "object.values": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz", + "integrity": "sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "enzyme-adapter-utils": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.12.0.tgz", + "integrity": "sha512-wkZvE0VxcFx/8ZsBw0iAbk3gR1d9hK447ebnSYBf95+r32ezBq+XDSAvRErkc4LZosgH8J7et7H7/7CtUuQfBA==", + "dev": true, + "requires": { + "airbnb-prop-types": "^2.13.2", + "function.prototype.name": "^1.1.0", + "object.assign": "^4.1.0", + "object.fromentries": "^2.0.0", + "prop-types": "^15.7.2", + "semver": "^5.6.0" + }, + "dependencies": { + "airbnb-prop-types": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.13.2.tgz", + "integrity": "sha512-2FN6DlHr6JCSxPPi25EnqGaXC4OC3/B3k1lCd6MMYrZ51/Gf/1qDfaR+JElzWa+Tl7cY2aYOlsYJGFeQyVHIeQ==", + "dev": true, + "requires": { + "array.prototype.find": "^2.0.4", + "function.prototype.name": "^1.1.0", + "has": "^1.0.3", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object.assign": "^4.1.0", + "object.entries": "^1.1.0", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.8.6" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "object.entries": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz", + "integrity": "sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "enzyme-to-json": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.3.5.tgz", + "integrity": "sha512-DmH1wJ68HyPqKSYXdQqB33ZotwfUhwQZW3IGXaNXgR69Iodaoj8TF/D9RjLdz4pEhGq2Tx2zwNUIjBuqoZeTgA==", + "dev": true, + "requires": { + "lodash": "^4.17.4" + } + }, + "equivalent-key-map": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/equivalent-key-map/-/equivalent-key-map-0.2.2.tgz", + "integrity": "sha512-xvHeyCDbZzkpN4VHQj/n+j2lOwL0VWszG30X4cOrc9Y7Tuo2qCdZK/0AMod23Z5dCtNUbaju6p0rwOhHUk05ew==" + }, + "err-code": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-1.1.2.tgz", + "integrity": "sha1-BuARbTAo9q70gGhJ6w6mp0iuaWA=", + "dev": true + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/error/-/error-7.0.2.tgz", + "integrity": "sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI=", + "dev": true, + "requires": { + "string-template": "~0.2.1", + "xtend": "~4.0.0" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "errorhandler": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.1.tgz", + "integrity": "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==", + "dev": true, + "requires": { + "accepts": "~1.3.7", + "escape-html": "~1.0.3" + }, + "dependencies": { + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "dev": true, + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", + "dev": true + } + } + }, + "es-abstract": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz", + "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==", + "requires": { + "es-to-primitive": "^1.1.1", + "function-bind": "^1.1.1", + "has": "^1.0.1", + "is-callable": "^1.1.3", + "is-regex": "^1.0.4" + } + }, + "es-to-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", + "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", + "requires": { + "is-callable": "^1.1.1", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.1" + } + }, + "es6-promise": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz", + "integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ==", + "dev": true + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "escodegen": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.10.0.tgz", + "integrity": "sha512-fjUOf8johsv23WuIKdNQU4P9t9jhQ4Qzx6pC2uW890OloK3Zs1ZAoCNpg/2larNF501jLl3UNy0kIRcF6VI22g==", + "dev": true, + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "eslint": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.1.0.tgz", + "integrity": "sha512-QhrbdRD7ofuV09IuE2ySWBz0FyXCq0rriLTZXZqaWSI79CVtHVRdkFuFTViiqzZhkCgfOh9USpriuGN2gIpZDQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.10.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^1.3.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^6.0.0", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^11.7.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^6.4.1", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.14", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^6.1.2", + "strip-ansi": "^5.2.0", + "strip-json-comments": "^3.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "acorn": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.1.tgz", + "integrity": "sha512-JD0xT5FCRDNyjDda3Lrg/IxFscp9q4tiYtxE1/nOzlKCk7hIRuYjhq1kCNkbPjMRMZuFq20HNQn1I9k8Oj0E+Q==", + "dev": true + }, + "acorn-jsx": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.0.1.tgz", + "integrity": "sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==", + "dev": true + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "eslint-scope": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", + "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "espree": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-6.0.0.tgz", + "integrity": "sha512-lJvCS6YbCn3ImT3yKkPe0+tJ+mH6ljhGNjHQH9mRtiO6gjhVAOhVXW1yjnwqGwTkK3bGbye+hb00nFNmu0l/1Q==", + "dev": true, + "requires": { + "acorn": "^6.0.7", + "acorn-jsx": "^5.0.0", + "eslint-visitor-keys": "^1.0.0" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "glob-parent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", + "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + }, + "dependencies": { + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + } + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "inquirer": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.0.tgz", + "integrity": "sha512-scfHejeG/lVZSpvCXpsB4j/wQNPM5JC8kiElOI0OUTwmc1RTpXr4H32/HOlQHcZiYl2z2VElwuCVDRG8vFmbnA==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "rxjs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", + "integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + }, + "table": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.5.tgz", + "integrity": "sha512-oGa2Hl7CQjfoaogtrOHEJroOcYILTx7BZWLGsJIlzoWmB2zmguhNfPJZsWPKYek/MgCxfco54gEi31d1uN2hFA==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + } + } + }, + "v8-compile-cache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", + "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", + "dev": true + } + } + }, + "eslint-import-resolver-node": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz", + "integrity": "sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "eslint-module-utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.4.1.tgz", + "integrity": "sha512-H6DOj+ejw7Tesdgbfs4jeS4YMFrT8uI8xwd1gtQqXssaR0EQ26L+2O/w6wkYFy2MymON0fTwHmXBvvfLNZVZEw==", + "dev": true, + "requires": { + "debug": "^2.6.8", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "eslint-plugin-eslint-comments": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.1.2.tgz", + "integrity": "sha512-QexaqrNeteFfRTad96W+Vi4Zj1KFbkHHNMMaHZEYcovKav6gdomyGzaxSDSL3GoIyUOo078wRAdYlu1caiauIQ==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "ignore": "^5.0.5" + }, + "dependencies": { + "ignore": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.2.tgz", + "integrity": "sha512-vdqWBp7MyzdmHkkRWV5nY+PfGRbYbahfuvsBCh277tq+w9zyNi7h5CYJCK0kmzti9kU+O/cB7sE8HvKv6aXAKQ==", + "dev": true + } + } + }, + "eslint-plugin-import": { + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.18.2.tgz", + "integrity": "sha512-5ohpsHAiUBRNaBWAF08izwUGlbrJoJJ+W9/TBwsGoR1MnlgfwMIKrFeSjWbt6moabiXW9xNvtFz+97KHRfI4HQ==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.2", + "eslint-module-utils": "^2.4.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.0", + "read-pkg-up": "^2.0.0", + "resolve": "^1.11.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "object.values": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz", + "integrity": "sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "eslint-plugin-jest": { + "version": "22.15.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.15.1.tgz", + "integrity": "sha512-CWq/RR/3tLaKFB+FZcCJwU9hH5q/bKeO3rFP8G07+q7hcDCFNqpvdphVbEbGE6o6qo1UbciEev4ejUWv7brUhw==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^1.13.0" + } + }, + "eslint-plugin-jsdoc": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-15.8.0.tgz", + "integrity": "sha512-J6ozWkaAgBh1eLdQE+C2wcXhoEgDmGJOSB6zMF5ktEtMBnU62xT3wfHcUacuTnv6rt+ollC0uZThaEpGA+sTNg==", + "dev": true, + "requires": { + "comment-parser": "^0.6.1", + "debug": "^4.1.1", + "flat-map-polyfill": "^0.3.8", + "jsdoctypeparser": "5.0.1", + "lodash": "^4.17.15", + "object.entries-ponyfill": "^1.0.1", + "regextras": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "eslint-plugin-jsx-a11y": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.2.3.tgz", + "integrity": "sha512-CawzfGt9w83tyuVekn0GDPU9ytYtxyxyFZ3aSWROmnRRFQFT2BiPJd7jvRdzNDi6oLWaS2asMeYSNMjWTV4eNg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.5", + "aria-query": "^3.0.0", + "array-includes": "^3.0.3", + "ast-types-flow": "^0.0.7", + "axobject-query": "^2.0.2", + "damerau-levenshtein": "^1.0.4", + "emoji-regex": "^7.0.2", + "has": "^1.0.3", + "jsx-ast-utils": "^2.2.1" + } + }, + "eslint-plugin-react": { + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz", + "integrity": "sha512-EzdyyBWC4Uz2hPYBiEJrKCUi2Fn+BJ9B/pJQcjw5X+x/H2Nm59S4MJIvL4O5NEE0+WbnQwEBxWY03oUk+Bc3FA==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "doctrine": "^2.1.0", + "has": "^1.0.3", + "jsx-ast-utils": "^2.1.0", + "object.entries": "^1.1.0", + "object.fromentries": "^2.0.0", + "object.values": "^1.1.0", + "prop-types": "^15.7.2", + "resolve": "^1.10.1" + }, + "dependencies": { + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "object.entries": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz", + "integrity": "sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "object.values": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz", + "integrity": "sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "eslint-plugin-react-hooks": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.6.1.tgz", + "integrity": "sha512-wHhmGJyVuijnYIJXZJHDUF2WM+rJYTjulUTqF9k61d3BTk8etydz+M4dXUVH7M76ZRS85rqBTCx0Es/lLsrjnA==", + "dev": true + }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.2.tgz", + "integrity": "sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.0.0" + } + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-4.0.0.tgz", + "integrity": "sha512-kapdTCt1bjmspxStVKX6huolXVV5ZfyZguY1lcfhVVZstce3bqxH9mcLzNn3/mlgW6wQ732+0fuG9v7h0ZQoKg==", + "dev": true, + "requires": { + "acorn": "^5.6.0", + "acorn-jsx": "^4.1.1" + } + }, + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true + }, + "events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz", + "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "exec-sh": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.2.tgz", + "integrity": "sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg==", + "dev": true + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "execall": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execall/-/execall-1.0.0.tgz", + "integrity": "sha1-c9CQTjlbPKsGWLCNCewlMH8pu3M=", + "dev": true, + "requires": { + "clone-regexp": "^1.0.0" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expect": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-24.7.1.tgz", + "integrity": "sha512-mGfvMTPduksV3xoI0xur56pQsg2vJjNf5+a+bXOjqCkiCBbmCayrBbHS/75y9K430cfqyocPr2ZjiNiRx4SRKw==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "ansi-styles": "^3.2.0", + "jest-get-type": "^24.3.0", + "jest-matcher-utils": "^24.7.0", + "jest-message-util": "^24.7.1", + "jest-regex-util": "^24.3.0" + } + }, + "expect-puppeteer": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/expect-puppeteer/-/expect-puppeteer-4.3.0.tgz", + "integrity": "sha512-p8N/KSVPG9PAOJlftK5f1n3JrULJ6Qq1EQ8r/n9xzkX2NmXbK8PcnJnkSAEzEHrMycELKGnlJV7M5nkgm+wEWA==", + "dev": true + }, + "express": { + "version": "4.16.3", + "resolved": "http://registry.npmjs.org/express/-/express-4.16.3.tgz", + "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.3", + "qs": "6.5.1", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "dev": true, + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "falafel": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.1.0.tgz", + "integrity": "sha1-lrsXdh2rqU9G0AFzizzt86Z/4Gw=", + "dev": true, + "requires": { + "acorn": "^5.0.0", + "foreach": "^2.0.5", + "isarray": "0.0.1", + "object-keys": "^1.0.6" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, + "fancy-log": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", + "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", + "dev": true, + "requires": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "parse-node-version": "^1.0.0", + "time-stamp": "^1.0.0" + } + }, + "fast-average-color": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-4.3.0.tgz", + "integrity": "sha512-k8FXd6+JeXoItmdNqB3hMwFgArryjdYBLuzEM8fRY/oztd/051yhSHU6GUrMOfIQU9dDHyFDcIAkGrQKlYtpDA==" + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "fast-glob": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz", + "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.1.2", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.3", + "micromatch": "^3.1.10" + } + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-memoize": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.1.tgz", + "integrity": "sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g==" + }, + "fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, + "fastq": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.6.0.tgz", + "integrity": "sha512-jmxqQ3Z/nXoeyDmWAzF9kH1aGZSis6e/SbfPmJpUnyZ0ogr6iscHQaml4wsEepEWSdtmpy+eVXmCRIMpxaXqOA==", + "dev": true, + "requires": { + "reusify": "^1.0.0" + } + }, + "faye-websocket": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", + "integrity": "sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "fb-watchman": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.0.tgz", + "integrity": "sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg=", + "dev": true, + "requires": { + "bser": "^2.0.0" + } + }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + } + } + }, + "fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "dev": true + }, + "fbjs-scripts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fbjs-scripts/-/fbjs-scripts-1.2.0.tgz", + "integrity": "sha512-5krZ8T0Bf8uky0abPoCLrfa7Orxd8UH4Qq8hRUF2RZYNMu+FmEOrBc7Ib3YVONmxTXTlLAvyrrdrVmksDb2OqQ==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "ansi-colors": "^1.0.1", + "babel-preset-fbjs": "^3.2.0", + "core-js": "^2.4.1", + "cross-spawn": "^5.1.0", + "fancy-log": "^1.3.2", + "object-assign": "^4.0.1", + "plugin-error": "^0.1.2", + "semver": "^5.1.0", + "through2": "^2.0.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, + "figgy-pudding": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", + "integrity": "sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w==", + "dev": true + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "fileset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/fileset/-/fileset-2.0.3.tgz", + "integrity": "sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA=", + "dev": true, + "requires": { + "glob": "^7.0.3", + "minimatch": "^3.0.3" + } + }, + "filesize": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz", + "integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==", + "dev": true + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "find-cache-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-1.0.0.tgz", + "integrity": "sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^1.0.0", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "find-file-up": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/find-file-up/-/find-file-up-0.1.3.tgz", + "integrity": "sha1-z2gJG8+fMApA2kEbN9pczlovvqA=", + "dev": true, + "requires": { + "fs-exists-sync": "^0.1.0", + "resolve-dir": "^0.1.0" + }, + "dependencies": { + "expand-tilde": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-1.2.2.tgz", + "integrity": "sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=", + "dev": true, + "requires": { + "os-homedir": "^1.0.1" + } + }, + "global-modules": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz", + "integrity": "sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=", + "dev": true, + "requires": { + "global-prefix": "^0.1.4", + "is-windows": "^0.2.0" + } + }, + "global-prefix": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-0.1.5.tgz", + "integrity": "sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.0", + "ini": "^1.3.4", + "is-windows": "^0.2.0", + "which": "^1.2.12" + } + }, + "is-windows": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz", + "integrity": "sha1-3hqm1j6indJIc3tp8f+LgALSEIw=", + "dev": true + }, + "resolve-dir": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-0.1.1.tgz", + "integrity": "sha1-shklmlYC+sXFxJatiUpujMQwJh4=", + "dev": true, + "requires": { + "expand-tilde": "^1.2.2", + "global-modules": "^0.2.3" + } + } + } + }, + "find-parent-dir": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/find-parent-dir/-/find-parent-dir-0.3.0.tgz", + "integrity": "sha1-M8RLQpqysvBkYpnF+fcY83b/jVQ=", + "dev": true + }, + "find-pkg": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/find-pkg/-/find-pkg-0.1.2.tgz", + "integrity": "sha1-G9wiwG42NlUy4qJIBGhUuXiNpVc=", + "dev": true, + "requires": { + "find-file-up": "^0.1.2" + } + }, + "find-process": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.2.tgz", + "integrity": "sha512-O83EVJr4dWvHJ7QpUzANNAMeQVKukRzRqRx4AIzdLYRrQorRdbqDwLPigkd9PYPhJRhmNPAoVjOm9bcwSmcZaw==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "commander": "^2.11.0", + "debug": "^2.6.8" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "requires": { + "locate-path": "^2.0.0" + } + }, + "findup": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/findup/-/findup-0.1.5.tgz", + "integrity": "sha1-itkpozk7rGJ5V6fl3kYjsGsOLOs=", + "dev": true, + "requires": { + "colors": "~0.6.0-1", + "commander": "~2.1.0" + }, + "dependencies": { + "colors": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "integrity": "sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w=", + "dev": true + }, + "commander": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz", + "integrity": "sha1-0SG7roYNmZKj1Re6lvVliOR8Z4E=", + "dev": true + } + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + }, + "dependencies": { + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "flat-map-polyfill": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/flat-map-polyfill/-/flat-map-polyfill-0.3.8.tgz", + "integrity": "sha512-ZfmD5MnU7GglUEhiky9C7yEPaNq1/wh36RDohe+Xr3nJVdccwHbdTkFIYvetcdsoAckUKT51fuf44g7Ni5Doyg==", + "dev": true + }, + "flatted": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.0.tgz", + "integrity": "sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg==", + "dev": true + }, + "flush-write-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", + "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.4" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "fs-exists-sync": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", + "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=", + "dev": true + }, + "fs-extra": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.0.1.tgz", + "integrity": "sha512-W+XLrggcDzlle47X/XnS7FXrXu9sDo+Ze9zpndeBxdgv88FHLm1HtmkhEwavruS6koanBjp098rUpHs65EmG7A==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs-minipass": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.6.tgz", + "integrity": "sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==", + "dev": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", + "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.3.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "function.prototype.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.0.tgz", + "integrity": "sha512-Bs0VRrTz4ghD8pTmbJQD1mZ8A/mN0ur/jGz+A6FBxPDUPkm1tNfF6bhTYPA7i7aF4lZJVr+OXTNNrnnIl58Wfg==", + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "is-callable": "^1.1.3" + } + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "requires": { + "globule": "^1.0.0" + } + }, + "genfun": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/genfun/-/genfun-5.0.0.tgz", + "integrity": "sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA==", + "dev": true + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==" + }, + "get-own-enumerable-property-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.0.tgz", + "integrity": "sha512-CIJYJC4GGF06TakLg8z4GQKvDsx9EMspVxOYih7LerEL/WosUnFIww45CGfxfeKHqlg3twgUrYRT1O3WQqjGCg==", + "dev": true + }, + "get-pkg-repo": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz", + "integrity": "sha1-xztInAbYDMVTbCyFP54FIyBWly0=", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "meow": "^3.3.0", + "normalize-package-data": "^2.3.0", + "parse-github-repo-url": "^1.3.0", + "through2": "^2.0.0" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + } + } + }, + "get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "gettext-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", + "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", + "requires": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.1" + } + }, + "git-raw-commits": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.0.tgz", + "integrity": "sha512-w4jFEJFgKXMQJ0H0ikBk2S+4KP2VEjhCvLCNqbNRQC8BgGWgLKNCO7a9K9LI+TVT7Gfoloje502sEnctibffgg==", + "dev": true, + "requires": { + "dargs": "^4.0.1", + "lodash.template": "^4.0.2", + "meow": "^4.0.0", + "split2": "^2.0.0", + "through2": "^2.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "git-remote-origin-url": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", + "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", + "dev": true, + "requires": { + "gitconfiglocal": "^1.0.0", + "pify": "^2.3.0" + } + }, + "git-semver-tags": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-2.0.3.tgz", + "integrity": "sha512-tj4FD4ww2RX2ae//jSrXZzrocla9db5h0V7ikPl1P/WwoZar9epdUhwR7XHXSgc+ZkNq72BEEerqQuicoEQfzA==", + "dev": true, + "requires": { + "meow": "^4.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "git-up": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-4.0.1.tgz", + "integrity": "sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw==", + "dev": true, + "requires": { + "is-ssh": "^1.3.0", + "parse-url": "^5.0.0" + } + }, + "git-url-parse": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.2.tgz", + "integrity": "sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ==", + "dev": true, + "requires": { + "git-up": "^4.0.0" + } + }, + "gitconfiglocal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", + "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", + "dev": true, + "requires": { + "ini": "^1.3.2" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dev": true, + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "global-cache": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/global-cache/-/global-cache-1.2.1.tgz", + "integrity": "sha512-EOeUaup5DgWKlCMhA9YFqNRIlZwoxt731jCh47WBV9fQqHgXhr3Fa55hfgIUqilIcPsfdNKN7LHjrNY+Km40KA==", + "requires": { + "define-properties": "^1.1.2", + "is-symbol": "^1.0.1" + } + }, + "global-modules-path": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/global-modules-path/-/global-modules-path-2.3.1.tgz", + "integrity": "sha512-y+shkf4InI7mPRHSo2b/k6ix6+NLDtyccYv86whhxrSGX9wjPX1VMITmrDbE1eh7zkzhiWtW2sHklJYoQ62Cxg==", + "dev": true + }, + "globals": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.0.0.tgz", + "integrity": "sha512-c9xoi32iDwlETiyYfO0pd3M8GcEuytJinSoqq7k3fz4H8p2p31NyfKr7JVd7Y0QvmtWcWXcwqW4L33eeDYgh1A==", + "dev": true, + "requires": { + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "globby": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz", + "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^1.0.2", + "dir-glob": "^2.2.2", + "fast-glob": "^2.2.6", + "glob": "^7.1.3", + "ignore": "^4.0.3", + "pify": "^4.0.1", + "slash": "^2.0.0" + }, + "dependencies": { + "dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "dev": true, + "requires": { + "path-type": "^3.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=", + "dev": true + }, + "globule": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", + "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", + "dev": true, + "requires": { + "glob": "~7.1.1", + "lodash": "^4.17.21", + "minimatch": "~3.0.2" + }, + "dependencies": { + "lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true + } + } + }, + "gonzales-pe": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.2.3.tgz", + "integrity": "sha512-Kjhohco0esHQnOiqqdJeNz/5fyPkOMD/d6XVjwTAoPGUFh0mCollPUTUTa2OZy4dYNAqlPIQdTiNzJTWdd9Htw==", + "dev": true, + "requires": { + "minimist": "1.1.x" + }, + "dependencies": { + "minimist": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz", + "integrity": "sha1-O+39kaktOQFvz6ocaB6Pqhoe/ag=", + "dev": true + } + } + }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", + "requires": { + "delegate": "^3.1.2" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "grapheme-breaker": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/grapheme-breaker/-/grapheme-breaker-0.3.2.tgz", + "integrity": "sha1-W55reMODJFLSuiuxy4MPlidkEKw=", + "dev": true, + "requires": { + "brfs": "^1.2.0", + "unicode-trie": "^0.3.1" + } + }, + "growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", + "dev": true + }, + "gzip-size": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", + "integrity": "sha512-5iI7omclyqrnWw4XbXAmGhPsABkSIDQonv2K0h61lybgofWa6iZyvrI3r2zsJH4P8Nb64fFVzlvfhs0g7BBxAA==", + "dev": true, + "requires": { + "duplexer": "^0.1.1", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "handlebars": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", + "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "dev": true, + "requires": { + "neo-async": "^2.6.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + }, + "dependencies": { + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + } + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hex-color-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", + "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", + "dev": true + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, + "homedir-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz", + "integrity": "sha1-TCu8inWJmP7r9e1oWA921GdotLw=", + "dev": true, + "requires": { + "parse-passwd": "^1.0.0" + } + }, + "hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "dev": true + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "dev": true + }, + "hpq": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/hpq/-/hpq-1.3.0.tgz", + "integrity": "sha512-fvYTvdCFOWQupGxqkahrkA+ERBuMdzkxwtUdKrxR6rmMd4Pfl+iZ1QiQYoaZ0B/v0y59MOMnz3XFUWbT50/NWA==" + }, + "hsl-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", + "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=", + "dev": true + }, + "hsla-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz", + "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", + "dev": true + }, + "html-comment-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.1.tgz", + "integrity": "sha1-ZouTd26q5V696POtRkswekljYl4=", + "dev": true + }, + "html-element-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.0.0.tgz", + "integrity": "sha512-/SP6aOiM5Ai9zALvCxDubIeez0LvG3qP7R9GcRDnJEP/HBmv0A8A9K0o8+HFudcFt46+i921ANjzKsjPjb7Enw==", + "dev": true, + "requires": { + "array-filter": "^1.0.0" + } + }, + "html-encoding-sniffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", + "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.1" + } + }, + "html-tags": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-2.0.0.tgz", + "integrity": "sha1-ELMKOGCF9Dzt41PMj6fLDe7qZos=", + "dev": true + }, + "htmlnano": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-0.2.3.tgz", + "integrity": "sha512-iS6T3J5gk2wInodbtMUyAU8sLYJOhuWDnIEd8lFRoHTypVGgawPHFEx2ZIK/XTErtDfwHBsrXeCwHAP8bdoSWw==", + "dev": true, + "requires": { + "cssnano": "^4.1.9", + "normalize-html-whitespace": "^0.2.0", + "object-assign": "^4.0.1", + "posthtml": "^0.11.3", + "posthtml-render": "^1.1.4", + "svgo": "^1.0.5", + "terser": "^3.16.1", + "uncss": "^0.16.2" + } + }, + "htmlparser2": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz", + "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=", + "dev": true, + "requires": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, + "http-cache-semantics": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", + "integrity": "sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==", + "dev": true + }, + "http-errors": { + "version": "1.6.3", + "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "http-parser-js": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.13.tgz", + "integrity": "sha1-O9bW/ebjFyyTNMOzO2wZPYD+ETc=", + "dev": true + }, + "http-proxy-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", + "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "dev": true, + "requires": { + "agent-base": "4", + "debug": "3.1.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "https-proxy-agent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", + "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "dev": true, + "requires": { + "agent-base": "^4.1.0", + "debug": "^3.1.0" + } + }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", + "dev": true, + "requires": { + "ms": "^2.0.0" + } + }, + "husky": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/husky/-/husky-3.0.5.tgz", + "integrity": "sha512-cKd09Jy9cDyNIvAdN2QQAP/oA21sle4FWXjIMDttailpLAYZuBE7WaPmhrkj+afS8Sj9isghAtFvWSQ0JiwOHg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cosmiconfig": "^5.2.1", + "execa": "^1.0.0", + "get-stdin": "^7.0.0", + "is-ci": "^2.0.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "read-pkg": "^5.1.1", + "run-node": "^1.0.0", + "slash": "^3.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + } + } + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=", + "dev": true + }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", + "dev": true + }, + "iferr": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", + "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", + "dev": true + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, + "ignore-walk": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", + "dev": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "image-size": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.6.3.tgz", + "integrity": "sha512-47xSUiQioGaB96nqtp5/q55m0aBQSQdyIloMOc/x+QVTDZLNmXE892IIDrJ0hM1A5vcNUDD5tDffkSP5lCaIIA==", + "dev": true + }, + "import-fresh": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.1.0.tgz", + "integrity": "sha512-PpuksHKGt8rXfWEr9m9EHIpgyyaltBy8+eF6GJM0QCAxMgxCfucMF3mjecK2QsJr0amJW7gTqh5/wht0z2UhEQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + } + } + }, + "import-lazy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz", + "integrity": "sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ==", + "dev": true + }, + "import-local": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", + "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", + "dev": true, + "requires": { + "pkg-dir": "^3.0.0", + "resolve-cwd": "^2.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + } + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "in-publish": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.1.tgz", + "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==", + "dev": true + }, + "indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "dev": true + }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", + "dev": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "init-package-json": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/init-package-json/-/init-package-json-1.10.3.tgz", + "integrity": "sha512-zKSiXKhQveNteyhcj1CoOP8tqp1QuxPIPBl8Bid99DGLFqA1p87M6lNgfjJHSBoWJJlidGOv5rWjyYKEB3g2Jw==", + "dev": true, + "requires": { + "glob": "^7.1.1", + "npm-package-arg": "^4.0.0 || ^5.0.0 || ^6.0.0", + "promzard": "^0.3.0", + "read": "~1.0.1", + "read-package-json": "1 || 2", + "semver": "2.x || 3.x || 4 || 5", + "validate-npm-package-license": "^3.0.1", + "validate-npm-package-name": "^3.0.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "inquirer": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.3.1.tgz", + "integrity": "sha512-MmL624rfkFt4TG9y/Jvmt8vdmOo836U7Y0Hxr2aFk3RelZEGX4Igk0KabWrcaaZaTv9uzglOqWh1Vly+FAWAXA==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.11", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "rxjs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", + "integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" + }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "dev": true + }, + "ipaddr.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", + "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=", + "dev": true + }, + "irregular-plurals": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-2.0.0.tgz", + "integrity": "sha512-Y75zBYLkh0lJ9qxeHlMjQ7bSbyiSqNW/UOPWDmzC7cXskL1hekSITh1Oc6JV0XCWWZ9DE8VYSB71xocLk3gmGw==", + "dev": true + }, + "is-absolute-url": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz", + "integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=", + "dev": true + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-alphabetical": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.2.tgz", + "integrity": "sha512-V0xN4BYezDHcBSKb1QHUFMlR4as/XEuCZBzMJUU4n7+Cbt33SmUnSol+pnXFvLxSHNq2CemUXNdaXV6Flg7+xg==", + "dev": true + }, + "is-alphanumeric": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz", + "integrity": "sha1-Spzvcdr0wAHB2B1j0UDPU/1oifQ=", + "dev": true + }, + "is-alphanumerical": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz", + "integrity": "sha512-pyfU/0kHdISIgslFfZN9nfY1Gk3MquQgUm1mJTjdkEPpkAKNWuBTSqFwewOpR7N351VkErCiyV71zX7mlQQqsg==", + "dev": true, + "requires": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-boolean-object": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.0.0.tgz", + "integrity": "sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "dev": true, + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==" + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-color-stop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz", + "integrity": "sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=", + "dev": true, + "requires": { + "css-color-names": "^0.0.4", + "hex-color-regex": "^1.1.0", + "hsl-regex": "^1.0.0", + "hsla-regex": "^1.0.0", + "rgb-regex": "^1.0.1", + "rgba-regex": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" + }, + "is-decimal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.2.tgz", + "integrity": "sha512-TRzl7mOCchnhchN+f3ICUCzYvL9ul7R+TYOsZ8xia++knyZAJfv/uA1FvQXsAnYIl1T3B2X5E/J7Wb1QXiIBXg==", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "dev": true, + "requires": { + "is-primitive": "^2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "is-generator-fn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.0.0.tgz", + "integrity": "sha512-elzyIdM7iKoFHzcrndIqjYomImhxrFRnGP3galODoII4TB9gI7mZ+FnlLQmmjf27SxHS2gKEeyhX5/+YRS6H9g==", + "dev": true + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-hexadecimal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz", + "integrity": "sha512-but/G3sapV3MNyqiDBLrOi4x8uCIw0RY3o/Vb5GT0sMFHrVV7731wFSVy41T5FO1og7G0gXLJh0MkgPRouko/A==", + "dev": true + }, + "is-html": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-html/-/is-html-1.1.0.tgz", + "integrity": "sha1-4E8cGNOUhRETlvmgJz6rUa8hhGQ=", + "dev": true, + "requires": { + "html-tags": "^1.0.0" + }, + "dependencies": { + "html-tags": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-1.2.0.tgz", + "integrity": "sha1-x43mW1Zjqll5id0rerSSANfk25g=", + "dev": true + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-number-object": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.3.tgz", + "integrity": "sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=", + "dev": true + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + }, + "is-observable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", + "integrity": "sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==", + "dev": true, + "requires": { + "symbol-observable": "^1.1.0" + } + }, + "is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true + }, + "is-path-inside": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.0.0.tgz", + "integrity": "sha512-OmUXvSq+P7aI/aRbl1dzwdlyLn8vW7Nr2/11S7y/dcLLgnQ89hgYJp7tfc+A5SRid3rNCLpruOp2CAV68/iOcA==", + "dev": true, + "requires": { + "path-is-inside": "^1.0.2" + } + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=", + "dev": true + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "requires": { + "has": "^1.0.1" + } + }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-ssh": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.1.tgz", + "integrity": "sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg==", + "dev": true, + "requires": { + "protocols": "^1.1.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-string": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.4.tgz", + "integrity": "sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ=", + "dev": true + }, + "is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", + "dev": true + }, + "is-supported-regexp-flag": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.1.tgz", + "integrity": "sha512-3vcJecUUrpgCqc/ca0aWeNu64UGgxcvO60K/Fkr1N6RSvfGCTU60UKN68JDmKokgba0rFFJs12EnzOQa14ubKQ==", + "dev": true + }, + "is-svg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz", + "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==", + "dev": true, + "requires": { + "html-comment-regex": "^1.1.0" + } + }, + "is-symbol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=" + }, + "is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "requires": { + "text-extensions": "^2.0.0" + } + }, + "is-touch-device": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-touch-device/-/is-touch-device-1.0.1.tgz", + "integrity": "sha512-LAYzo9kMT1b2p19L/1ATGt2XcSilnzNlyvq6c0pbPRVisLbAPpLqr53tIJS00kvrTkj0HtR8U7+u8X0yR8lPSw==" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "dev": true + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-whitespace-character": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz", + "integrity": "sha512-SzM+T5GKUCtLhlHFKt2SDAX2RFzfS6joT91F2/WSi9LxgFdsnhfPK/UIA+JhRR2xuyLdrCys2PiFDrtn1fU5hQ==", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-word-character": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.2.tgz", + "integrity": "sha512-T3FlsX8rCHAH8e7RE7PfOPZVFQlcV3XRF9eOOBQ1uf70OxO7CjjSOjeImMPCADBdYWcStAbVbYvJ1m2D3tb+EA==", + "dev": true + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + } + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul-api": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-2.1.1.tgz", + "integrity": "sha512-kVmYrehiwyeBAk/wE71tW6emzLiHGjYIiDrc8sfyty4F8M02/lrgXSm+R1kXysmF20zArvmZXjlE/mg24TVPJw==", + "dev": true, + "requires": { + "async": "^2.6.1", + "compare-versions": "^3.2.1", + "fileset": "^2.0.3", + "istanbul-lib-coverage": "^2.0.3", + "istanbul-lib-hook": "^2.0.3", + "istanbul-lib-instrument": "^3.1.0", + "istanbul-lib-report": "^2.0.4", + "istanbul-lib-source-maps": "^3.0.2", + "istanbul-reports": "^2.1.1", + "js-yaml": "^3.12.0", + "make-dir": "^1.3.0", + "minimatch": "^3.0.4", + "once": "^1.4.0" + }, + "dependencies": { + "istanbul-lib-instrument": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.1.0.tgz", + "integrity": "sha512-ooVllVGT38HIk8MxDj/OIHXSYvH+1tq/Vb38s8ixt9GoJadXska4WkGY+0wkmtYCZNYtaARniH/DixUGGLZ0uA==", + "dev": true, + "requires": { + "@babel/generator": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "istanbul-lib-coverage": "^2.0.3", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-dKWuzRGCs4G+67VfW9pBFFz2Jpi4vSp/k7zBcJ888ofV5Mi1g5CUML5GvMvV6u9Cjybftu+E8Cgp+k0dI1E5lw==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-2.0.3.tgz", + "integrity": "sha512-CLmEqwEhuCYtGcpNVJjLV1DQyVnIqavMLFHV/DP+np/g3qvdxu3gsPqYoJMXm15sN84xOlckFB3VNvRbf5yEgA==", + "dev": true, + "requires": { + "append-transform": "^1.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.1.0.tgz", + "integrity": "sha512-ooVllVGT38HIk8MxDj/OIHXSYvH+1tq/Vb38s8ixt9GoJadXska4WkGY+0wkmtYCZNYtaARniH/DixUGGLZ0uA==", + "dev": true, + "requires": { + "@babel/generator": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "istanbul-lib-coverage": "^2.0.3", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-2.0.4.tgz", + "integrity": "sha512-sOiLZLAWpA0+3b5w5/dq0cjm2rrNdAfHWaGhmn7XEFW6X++IV9Ohn+pnELAl9K3rfpaeBfbmH9JU5sejacdLeA==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^2.0.3", + "make-dir": "^1.3.0", + "supports-color": "^6.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.2.tgz", + "integrity": "sha512-JX4v0CiKTGp9fZPmoxpu9YEkPbEqCqBbO3403VabKjH+NRXo72HafD5UgnjTEqHL2SAjaZK1XDuDOkn6I5QVfQ==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.3", + "make-dir": "^1.3.0", + "rimraf": "^2.6.2", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.1.1.tgz", + "integrity": "sha512-FzNahnidyEPBCI0HcufJoSEoKykesRlFcSzQqjH9x0+LC8tnnE/p/90PBLu8iZTxr8yYZNyTtiAujUqyN+CIxw==", + "dev": true, + "requires": { + "handlebars": "^4.1.0" + } + }, + "jest": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-24.7.1.tgz", + "integrity": "sha512-AbvRar5r++izmqo5gdbAjTeA6uNRGoNRuj5vHB0OnDXo2DXWZJVuaObiGgtlvhKb+cWy2oYbQSfxv7Q7GjnAtA==", + "dev": true, + "requires": { + "import-local": "^2.0.0", + "jest-cli": "^24.7.1" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "import-local": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", + "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", + "dev": true, + "requires": { + "pkg-dir": "^3.0.0", + "resolve-cwd": "^2.0.0" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "jest-cli": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-24.7.1.tgz", + "integrity": "sha512-32OBoSCVPzcTslGFl6yVCMzB2SqX3IrWwZCY5mZYkb0D2WsogmU3eV2o8z7+gRQa4o4sZPX/k7GU+II7CxM6WQ==", + "dev": true, + "requires": { + "@jest/core": "^24.7.1", + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "import-local": "^2.0.0", + "is-ci": "^2.0.0", + "jest-config": "^24.7.1", + "jest-util": "^24.7.1", + "jest-validate": "^24.7.0", + "prompts": "^2.0.1", + "realpath-native": "^1.1.0", + "yargs": "^12.0.2" + } + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "jest-changed-files": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-24.7.0.tgz", + "integrity": "sha512-33BgewurnwSfJrW7T5/ZAXGE44o7swLslwh8aUckzq2e17/2Os1V0QU506ZNik3hjs8MgnEMKNkcud442NCDTw==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "execa": "^1.0.0", + "throat": "^4.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, + "jest-config": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-24.7.1.tgz", + "integrity": "sha512-8FlJNLI+X+MU37j7j8RE4DnJkvAghXmBWdArVzypW6WxfGuxiL/CCkzBg0gHtXhD2rxla3IMOSUAHylSKYJ83g==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/test-sequencer": "^24.7.1", + "@jest/types": "^24.7.0", + "babel-jest": "^24.7.1", + "chalk": "^2.0.1", + "glob": "^7.1.1", + "jest-environment-jsdom": "^24.7.1", + "jest-environment-node": "^24.7.1", + "jest-get-type": "^24.3.0", + "jest-jasmine2": "^24.7.1", + "jest-regex-util": "^24.3.0", + "jest-resolve": "^24.7.1", + "jest-util": "^24.7.1", + "jest-validate": "^24.7.0", + "micromatch": "^3.1.10", + "pretty-format": "^24.7.0", + "realpath-native": "^1.1.0" + }, + "dependencies": { + "jest-get-type": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.3.0.tgz", + "integrity": "sha512-HYF6pry72YUlVcvUx3sEpMRwXEWGEPlJ0bSPVnB3b3n++j4phUEoSPcS6GC0pPJ9rpyPSe4cb5muFo6D39cXow==", + "dev": true + } + } + }, + "jest-dev-server": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jest-dev-server/-/jest-dev-server-4.3.0.tgz", + "integrity": "sha512-bC9flKY2G1honQ/UI0gEhb0wFnDhpFr7xidC8Nk+evi7TgnNtfsGIzzF2dcIhF1G9BGF0n/M7CJrMAzwQhyTPA==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cwd": "^0.10.0", + "find-process": "^1.4.2", + "prompts": "^2.1.0", + "spawnd": "^4.0.0", + "tree-kill": "^1.2.1", + "wait-on": "^3.3.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "prompts": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.1.0.tgz", + "integrity": "sha512-+x5TozgqYdOwWsQFZizE/Tra3fKvAoy037kOyU6cgz84n8f6zxngLOV4O32kTwt9FcLCxAqw0P/c8rOr9y+Gfg==", + "dev": true, + "requires": { + "kleur": "^3.0.2", + "sisteransi": "^1.0.0" + } + }, + "tree-kill": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.1.tgz", + "integrity": "sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q==", + "dev": true + } + } + }, + "jest-diff": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.7.0.tgz", + "integrity": "sha512-ULQZ5B1lWpH70O4xsANC4tf4Ko6RrpwhE3PtG6ERjMg1TiYTC2Wp4IntJVGro6a8HG9luYHhhmF4grF0Pltckg==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff-sequences": "^24.3.0", + "jest-get-type": "^24.3.0", + "pretty-format": "^24.7.0" + } + }, + "jest-docblock": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-24.3.0.tgz", + "integrity": "sha512-nlANmF9Yq1dufhFlKG9rasfQlrY7wINJbo3q01tu56Jv5eBU5jirylhF2O5ZBnLxzOVBGRDz/9NAwNyBtG4Nyg==", + "dev": true, + "requires": { + "detect-newline": "^2.1.0" + } + }, + "jest-each": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-24.7.1.tgz", + "integrity": "sha512-4fsS8fEfLa3lfnI1Jw6NxjhyRTgfpuOVTeUZZFyVYqeTa4hPhr2YkToUhouuLTrL2eMGOfpbdMyRx0GQ/VooKA==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "chalk": "^2.0.1", + "jest-get-type": "^24.3.0", + "jest-util": "^24.7.1", + "pretty-format": "^24.7.0" + }, + "dependencies": { + "jest-get-type": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.3.0.tgz", + "integrity": "sha512-HYF6pry72YUlVcvUx3sEpMRwXEWGEPlJ0bSPVnB3b3n++j4phUEoSPcS6GC0pPJ9rpyPSe4cb5muFo6D39cXow==", + "dev": true + } + } + }, + "jest-environment-jsdom": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-24.7.1.tgz", + "integrity": "sha512-Gnhb+RqE2JuQGb3kJsLF8vfqjt3PHKSstq4Xc8ic+ax7QKo4Z0RWGucU3YV+DwKR3T9SYc+3YCUQEJs8r7+Jxg==", + "dev": true, + "requires": { + "@jest/environment": "^24.7.1", + "@jest/fake-timers": "^24.7.1", + "@jest/types": "^24.7.0", + "jest-mock": "^24.7.0", + "jest-util": "^24.7.1", + "jsdom": "^11.5.1" + } + }, + "jest-environment-node": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-24.7.1.tgz", + "integrity": "sha512-GJJQt1p9/C6aj6yNZMvovZuxTUd+BEJprETdvTKSb4kHcw4mFj8777USQV0FJoJ4V3djpOwA5eWyPwfq//PFBA==", + "dev": true, + "requires": { + "@jest/environment": "^24.7.1", + "@jest/fake-timers": "^24.7.1", + "@jest/types": "^24.7.0", + "jest-mock": "^24.7.0", + "jest-util": "^24.7.1" + } + }, + "jest-environment-puppeteer": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-puppeteer/-/jest-environment-puppeteer-4.3.0.tgz", + "integrity": "sha512-ZighMsU39bnacn2ylyHb88CB+ldgCfXGD3lS78k4PEo8A8xyt6+2mxmSR62FH3Y7K+W2gPDu5+QM3/LZuq42fQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cwd": "^0.10.0", + "jest-dev-server": "^4.3.0", + "merge-deep": "^3.0.2" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "jest-get-type": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.3.0.tgz", + "integrity": "sha512-HYF6pry72YUlVcvUx3sEpMRwXEWGEPlJ0bSPVnB3b3n++j4phUEoSPcS6GC0pPJ9rpyPSe4cb5muFo6D39cXow==", + "dev": true + }, + "jest-haste-map": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-24.7.1.tgz", + "integrity": "sha512-g0tWkzjpHD2qa03mTKhlydbmmYiA2KdcJe762SbfFo/7NIMgBWAA0XqQlApPwkWOF7Cxoi/gUqL0i6DIoLpMBw==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "anymatch": "^2.0.0", + "fb-watchman": "^2.0.0", + "fsevents": "^1.2.7", + "graceful-fs": "^4.1.15", + "invariant": "^2.2.4", + "jest-serializer": "^24.4.0", + "jest-util": "^24.7.1", + "jest-worker": "^24.6.0", + "micromatch": "^3.1.10", + "sane": "^4.0.3", + "walker": "^1.0.7" + }, + "dependencies": { + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + } + } + }, + "jest-jasmine2": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-24.7.1.tgz", + "integrity": "sha512-Y/9AOJDV1XS44wNwCaThq4Pw3gBPiOv/s6NcbOAkVRRUEPu+36L2xoPsqQXsDrxoBerqeyslpn2TpCI8Zr6J2w==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@jest/environment": "^24.7.1", + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "chalk": "^2.0.1", + "co": "^4.6.0", + "expect": "^24.7.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^24.7.1", + "jest-matcher-utils": "^24.7.0", + "jest-message-util": "^24.7.1", + "jest-runtime": "^24.7.1", + "jest-snapshot": "^24.7.1", + "jest-util": "^24.7.1", + "pretty-format": "^24.7.0", + "throat": "^4.0.0" + } + }, + "jest-junit": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-6.4.0.tgz", + "integrity": "sha512-GXEZA5WBeUich94BARoEUccJumhCgCerg7mXDFLxWwI2P7wL3Z7sGWk+53x343YdBLjiMR9aD/gYMVKO+0pE4Q==", + "dev": true, + "requires": { + "jest-validate": "^24.0.0", + "mkdirp": "^0.5.1", + "strip-ansi": "^4.0.0", + "xml": "^1.0.1" + } + }, + "jest-leak-detector": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-24.7.0.tgz", + "integrity": "sha512-zV0qHKZGXtmPVVzT99CVEcHE9XDf+8LwiE0Ob7jjezERiGVljmqKFWpV2IkG+rkFIEUHFEkMiICu7wnoPM/RoQ==", + "dev": true, + "requires": { + "pretty-format": "^24.7.0" + } + }, + "jest-matcher-utils": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-24.7.0.tgz", + "integrity": "sha512-158ieSgk3LNXeUhbVJYRXyTPSCqNgVXOp/GT7O94mYd3pk/8+odKTyR1JLtNOQSPzNi8NFYVONtvSWA/e1RDXg==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-diff": "^24.7.0", + "jest-get-type": "^24.3.0", + "pretty-format": "^24.7.0" + } + }, + "jest-message-util": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.7.1.tgz", + "integrity": "sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^2.0.1", + "micromatch": "^3.1.10", + "slash": "^2.0.0", + "stack-utils": "^1.0.1" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "jest-mock": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-24.7.0.tgz", + "integrity": "sha512-6taW4B4WUcEiT2V9BbOmwyGuwuAFT2G8yghF7nyNW1/2gq5+6aTqSPcS9lS6ArvEkX55vbPAS/Jarx5LSm4Fng==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0" + } + }, + "jest-pnp-resolver": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz", + "integrity": "sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ==", + "dev": true + }, + "jest-puppeteer": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jest-puppeteer/-/jest-puppeteer-4.3.0.tgz", + "integrity": "sha512-WXhaWlbQl01xadZyNmdZntrtIr8uWUmgjPogDih7dOnr3G/xRr3A034SCqdjwV6fE0tqz7c5hwO8oBTyGZPRgA==", + "dev": true, + "requires": { + "expect-puppeteer": "^4.3.0", + "jest-environment-puppeteer": "^4.3.0" + } + }, + "jest-regex-util": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.3.0.tgz", + "integrity": "sha512-tXQR1NEOyGlfylyEjg1ImtScwMq8Oh3iJbGTjN7p0J23EuVX1MA8rwU69K4sLbCmwzgCUbVkm0FkSF9TdzOhtg==", + "dev": true + }, + "jest-resolve": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-24.7.1.tgz", + "integrity": "sha512-Bgrc+/UUZpGJ4323sQyj85hV9d+ANyPNu6XfRDUcyFNX1QrZpSoM0kE4Mb2vZMAYTJZsBFzYe8X1UaOkOELSbw==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "browser-resolve": "^1.11.3", + "chalk": "^2.0.1", + "jest-pnp-resolver": "^1.2.1", + "realpath-native": "^1.1.0" + } + }, + "jest-resolve-dependencies": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-24.7.1.tgz", + "integrity": "sha512-2Eyh5LJB2liNzfk4eo7bD1ZyBbqEJIyyrFtZG555cSWW9xVHxII2NuOkSl1yUYTAYCAmM2f2aIT5A7HzNmubyg==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "jest-regex-util": "^24.3.0", + "jest-snapshot": "^24.7.1" + } + }, + "jest-runner": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-24.7.1.tgz", + "integrity": "sha512-aNFc9liWU/xt+G9pobdKZ4qTeG/wnJrJna3VqunziDNsWT3EBpmxXZRBMKCsNMyfy+A/XHiV+tsMLufdsNdgCw==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/environment": "^24.7.1", + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "chalk": "^2.4.2", + "exit": "^0.1.2", + "graceful-fs": "^4.1.15", + "jest-config": "^24.7.1", + "jest-docblock": "^24.3.0", + "jest-haste-map": "^24.7.1", + "jest-jasmine2": "^24.7.1", + "jest-leak-detector": "^24.7.0", + "jest-message-util": "^24.7.1", + "jest-resolve": "^24.7.1", + "jest-runtime": "^24.7.1", + "jest-util": "^24.7.1", + "jest-worker": "^24.6.0", + "source-map-support": "^0.5.6", + "throat": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + } + } + }, + "jest-runtime": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-24.7.1.tgz", + "integrity": "sha512-0VAbyBy7tll3R+82IPJpf6QZkokzXPIS71aDeqh+WzPRXRCNz6StQ45otFariPdJ4FmXpDiArdhZrzNAC3sj6A==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/environment": "^24.7.1", + "@jest/source-map": "^24.3.0", + "@jest/transform": "^24.7.1", + "@jest/types": "^24.7.0", + "@types/yargs": "^12.0.2", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.1.15", + "jest-config": "^24.7.1", + "jest-haste-map": "^24.7.1", + "jest-message-util": "^24.7.1", + "jest-mock": "^24.7.0", + "jest-regex-util": "^24.3.0", + "jest-resolve": "^24.7.1", + "jest-snapshot": "^24.7.1", + "jest-util": "^24.7.1", + "jest-validate": "^24.7.0", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "strip-bom": "^3.0.0", + "yargs": "^12.0.2" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "jest-serializer": { + "version": "24.4.0", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-24.4.0.tgz", + "integrity": "sha512-k//0DtglVstc1fv+GY/VHDIjrtNjdYvYjMlbLUed4kxrE92sIUewOi5Hj3vrpB8CXfkJntRPDRjCrCvUhBdL8Q==", + "dev": true + }, + "jest-serializer-enzyme": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jest-serializer-enzyme/-/jest-serializer-enzyme-1.0.0.tgz", + "integrity": "sha1-+LUJDRrk0QW7rr8E0zvl3ymI8Fk=", + "dev": true, + "requires": { + "enzyme-to-json": "^1.4.4" + }, + "dependencies": { + "enzyme-to-json": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-1.6.0.tgz", + "integrity": "sha512-izMrbriQySEiWDUR0NeAyzCiRBncgDjfX5bt3xobkyUinEA79q8UuBNUfWFyjX2ahhP2G8k1GN4kG9NAUF405g==", + "dev": true, + "requires": { + "lodash.filter": "^4.6.0", + "lodash.isnil": "^4.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.omitby": "^4.6.0", + "lodash.range": "^3.2.0", + "object-values": "^1.0.0", + "object.entries": "^1.0.4" + } + } + } + }, + "jest-snapshot": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-24.7.1.tgz", + "integrity": "sha512-8Xk5O4p+JsZZn4RCNUS3pxA+ORKpEKepE+a5ejIKrId9CwrVN0NY+vkqEkXqlstA5NMBkNahXkR/4qEBy0t5yA==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0", + "@jest/types": "^24.7.0", + "chalk": "^2.0.1", + "expect": "^24.7.1", + "jest-diff": "^24.7.0", + "jest-matcher-utils": "^24.7.0", + "jest-message-util": "^24.7.1", + "jest-resolve": "^24.7.1", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^24.7.0", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "jest-util": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-24.7.1.tgz", + "integrity": "sha512-/KilOue2n2rZ5AnEBYoxOXkeTu6vi7cjgQ8MXEkih0oeAXT6JkS3fr7/j8+engCjciOU1Nq5loMSKe0A1oeX0A==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/fake-timers": "^24.7.1", + "@jest/source-map": "^24.3.0", + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "callsites": "^3.0.0", + "chalk": "^2.0.1", + "graceful-fs": "^4.1.15", + "is-ci": "^2.0.0", + "mkdirp": "^0.5.1", + "slash": "^2.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "jest-validate": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-24.7.0.tgz", + "integrity": "sha512-cgai/gts9B2chz1rqVdmLhzYxQbgQurh1PEQSvSgPZ8KGa1AqXsqC45W5wKEwzxKrWqypuQrQxnF4+G9VejJJA==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "camelcase": "^5.0.0", + "chalk": "^2.0.1", + "jest-get-type": "^24.3.0", + "leven": "^2.1.0", + "pretty-format": "^24.7.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "jest-get-type": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.3.0.tgz", + "integrity": "sha512-HYF6pry72YUlVcvUx3sEpMRwXEWGEPlJ0bSPVnB3b3n++j4phUEoSPcS6GC0pPJ9rpyPSe4cb5muFo6D39cXow==", + "dev": true + } + } + }, + "jest-watcher": { + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-24.7.1.tgz", + "integrity": "sha512-Wd6TepHLRHVKLNPacEsBwlp9raeBIO+01xrN24Dek4ggTS8HHnOzYSFnvp+6MtkkJ3KfMzy220KTi95e2rRkrw==", + "dev": true, + "requires": { + "@jest/test-result": "^24.7.1", + "@jest/types": "^24.7.0", + "@types/yargs": "^12.0.9", + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.1", + "jest-util": "^24.7.1", + "string-length": "^2.0.0" + } + }, + "jest-worker": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.6.0.tgz", + "integrity": "sha512-jDwgW5W9qGNvpI1tNnvajh0a5IE/PuGLFmHk6aR/BZFz8tSgGw17GsDPXAJ6p91IvYDjOw8GpFbvvZGAK+DPQQ==", + "dev": true, + "requires": { + "merge-stream": "^1.0.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "js-base64": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "dev": true + }, + "js-beautify": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.9.1.tgz", + "integrity": "sha512-oxxvVZdOdUfzk8IOLBF2XUZvl2GoBEfA+b0of4u2EBY/46NlXasi8JdFvazA5lCrf9/lQhTjyVy2QCUW7iq0MQ==", + "dev": true, + "requires": { + "config-chain": "^1.1.12", + "editorconfig": "^0.15.2", + "glob": "^7.1.3", + "mkdirp": "~0.5.0", + "nopt": "~4.0.1" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "nopt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "dev": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + } + } + }, + "js-levenshtein": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.3.tgz", + "integrity": "sha512-/812MXr9RBtMObviZ8gQBhHO8MOrGj8HlEE+4ccMTElNA/6I3u39u+bhny55Lk921yn44nSZFy9naNLElL5wgQ==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + } + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true, + "optional": true + }, + "jsc-android": { + "version": "245459.0.0", + "resolved": "https://registry.npmjs.org/jsc-android/-/jsc-android-245459.0.0.tgz", + "integrity": "sha512-wkjURqwaB1daNkDi2OYYbsLnIdC/lUM2nPXQKRs5pqEU9chDg435bjvo+LSaHotDENygHQDHe+ntUkkw2gwMtg==", + "dev": true + }, + "jsdoctypeparser": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-5.0.1.tgz", + "integrity": "sha512-dYwcK6TKzvq+ZKtbp4sbQSW9JMo6s+4YFfUs5D/K7bZsn3s1NhEhZ+jmIPzby0HbkbECBe+hNPEa6a+E21o94w==", + "dev": true + }, + "jsdom": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", + "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "acorn": "^5.5.3", + "acorn-globals": "^4.1.0", + "array-equal": "^1.0.0", + "cssom": ">= 0.3.2 < 0.4.0", + "cssstyle": "^1.0.0", + "data-urls": "^1.0.0", + "domexception": "^1.0.1", + "escodegen": "^1.9.1", + "html-encoding-sniffer": "^1.0.2", + "left-pad": "^1.3.0", + "nwsapi": "^2.0.7", + "parse5": "4.0.0", + "pn": "^1.1.0", + "request": "^2.87.0", + "request-promise-native": "^1.0.5", + "sax": "^1.2.4", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.3.4", + "w3c-hr-time": "^1.0.1", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.3", + "whatwg-mimetype": "^2.1.0", + "whatwg-url": "^6.4.1", + "ws": "^5.2.0", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "abab": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz", + "integrity": "sha512-sY5AXXVZv4Y1VACTtR11UJCPHHudgY5i26Qj5TypE6DKlIApbwb5uqhXcJ5UUGbvZNRh7EeIoW+LrJumBsKp7w==", + "dev": true + }, + "cssstyle": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-1.1.1.tgz", + "integrity": "sha512-364AI1l/M5TYcFH83JnOH/pSqgaNnKmYgKrm0didZMGKWjQB60dymwWy1rKUgL3J1ffdq9xVi2yGLHdSjjSNog==", + "dev": true, + "requires": { + "cssom": "0.3.x" + } + }, + "nwsapi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.0.9.tgz", + "integrity": "sha512-nlWFSCTYQcHk/6A9FFnfhKc14c3aFhfdNBXgo8Qgi9QTBu/qg3Ww+Uiz9wMzXd1T8GFxPc2QIHB6Qtf2XFryFQ==", + "dev": true + }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", + "dev": true + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha1-5CGiqOINawgZ3yiQj3glJrlt0f4=", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json2php": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.4.tgz", + "integrity": "sha1-a9haHdpqXdfpECK7JEA8wbfC7jQ=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jsx-ast-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.1.tgz", + "integrity": "sha512-v3FxCcAf20DayI+uxnCuw795+oOIkVu6EnJ1+kSzhqqTZHNkTZ7B66ZgLp4oLJ/gbA64cI0B7WRoHZMSRdyVRQ==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "object.assign": "^4.1.0" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "kleur": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.2.tgz", + "integrity": "sha512-3h7B2WRT5LNXOtQiAaWonilegHcPSf9nLVXlSTci8lu1dZUuui61+EsPEZqSVxY7rXYmB2DVKMQILxaO5WL61Q==", + "dev": true + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "requires": { + "invert-kv": "^1.0.0" + } + }, + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", + "dev": true + }, + "lerna": { + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/lerna/-/lerna-3.16.4.tgz", + "integrity": "sha512-0HfwXIkqe72lBLZcNO9NMRfylh5Ng1l8tETgYQ260ZdHRbPuaLKE3Wqnd2YYRRkWfwPyEyZO8mZweBR+slVe1A==", + "dev": true, + "requires": { + "@lerna/add": "3.16.2", + "@lerna/bootstrap": "3.16.2", + "@lerna/changed": "3.16.4", + "@lerna/clean": "3.16.0", + "@lerna/cli": "3.13.0", + "@lerna/create": "3.16.0", + "@lerna/diff": "3.16.0", + "@lerna/exec": "3.16.0", + "@lerna/import": "3.16.0", + "@lerna/init": "3.16.0", + "@lerna/link": "3.16.2", + "@lerna/list": "3.16.0", + "@lerna/publish": "3.16.4", + "@lerna/run": "3.16.0", + "@lerna/version": "3.16.4", + "import-local": "^2.0.0", + "npmlog": "^4.1.2" + } + }, + "leven": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", + "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "line-height": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz", + "integrity": "sha1-SxIF7d4YKHKl76PI9iCzGHqcVMk=", + "requires": { + "computed-style": "~0.1.3" + } + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "lint-staged": { + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-9.2.5.tgz", + "integrity": "sha512-d99gTBFMJ29159+9iRvaMEQstmNcPAbQbhHSYw6D/1FncvFdIj8lWHztaq3Uq+tbZPABHXQ/fyN7Rp1QwF8HIw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "commander": "^2.20.0", + "cosmiconfig": "^5.2.1", + "debug": "^4.1.1", + "dedent": "^0.7.0", + "del": "^5.0.0", + "execa": "^2.0.3", + "listr": "^0.14.3", + "log-symbols": "^3.0.0", + "micromatch": "^4.0.2", + "normalize-path": "^3.0.0", + "please-upgrade-node": "^3.1.1", + "string-argv": "^0.3.0", + "stringify-object": "^3.3.0" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "execa": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/execa/-/execa-2.0.4.tgz", + "integrity": "sha512-VcQfhuGD51vQUQtKIq2fjGDLDbL6N1DTQVpYzxZ7LPIXw3HqTuIz6uxRmpV1qf8i31LHf2kjiaGI+GdHwRgbnQ==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.5", + "get-stream": "^5.0.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^3.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "get-stream": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", + "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + }, + "dependencies": { + "path-key": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.0.tgz", + "integrity": "sha512-8cChqz0RP6SHJkMt48FW0A7+qUOn+OsnOsVtzI59tZ8m+5bCSk7hzwET0pulwOM2YMn9J1efb07KB9l9f30SGg==", + "dev": true + } + } + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "listr": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", + "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", + "dev": true, + "requires": { + "@samverschueren/stream-to-observable": "^0.3.0", + "is-observable": "^1.1.0", + "is-promise": "^2.1.0", + "is-stream": "^1.1.0", + "listr-silent-renderer": "^1.1.1", + "listr-update-renderer": "^0.5.0", + "listr-verbose-renderer": "^0.5.0", + "p-map": "^2.0.0", + "rxjs": "^6.3.3" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "listr-silent-renderer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", + "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=", + "dev": true + }, + "listr-update-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz", + "integrity": "sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "cli-truncate": "^0.2.1", + "elegant-spinner": "^1.0.1", + "figures": "^1.7.0", + "indent-string": "^3.0.0", + "log-symbols": "^1.0.2", + "log-update": "^2.3.0", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "dev": true, + "requires": { + "chalk": "^1.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "listr-verbose-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz", + "integrity": "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "cli-cursor": "^2.1.0", + "date-fns": "^1.27.2", + "figures": "^2.0.0" + } + }, + "livereload-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.3.0.tgz", + "integrity": "sha512-j1R0/FeGa64Y+NmqfZhyoVRzcFlOZ8sNlKzHjh4VvLULFACZhn68XrX5DFg2FhMvSMJmROuFxRSa560ECWKBMg==", + "dev": true + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "loader-runner": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", + "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==", + "dev": true + }, + "loader-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", + "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=", + "dev": true, + "requires": { + "big.js": "^3.1.3", + "emojis-list": "^2.0.0", + "json5": "^0.5.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, + "lodash.clone": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=", + "dev": true + }, + "lodash.filter": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", + "integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4=", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, + "lodash.ismatch": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", + "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", + "dev": true + }, + "lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw=", + "dev": true + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "dev": true + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, + "lodash.omitby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.omitby/-/lodash.omitby-4.6.0.tgz", + "integrity": "sha1-XBX/R1StVVAWtTwEExHo8HkgR5E=", + "dev": true + }, + "lodash.range": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.range/-/lodash.range-3.2.0.tgz", + "integrity": "sha1-9GHliPZmg/fq3q3lE+OKaaVloV0=", + "dev": true + }, + "lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", + "dev": true + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "lodash.tail": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.tail/-/lodash.tail-4.1.1.tgz", + "integrity": "sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=", + "dev": true + }, + "lodash.template": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", + "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0" + } + }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=", + "dev": true + }, + "lodash.unescape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", + "integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=", + "dev": true + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "log-update": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz", + "integrity": "sha1-iDKP19HOeTiykoN0bwsbwSayRwg=", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "cli-cursor": "^2.0.0", + "wrap-ansi": "^3.0.1" + }, + "dependencies": { + "wrap-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz", + "integrity": "sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0" + } + } + } + }, + "logkitty": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.5.0.tgz", + "integrity": "sha512-UA06TmPaSPiHxMBlo5uxL3ZvjJ2Gx/rEECrqowHsIsNoAoSB8aBSP553Fr2FJhOp3it2ulLsd520DZWS1IaYOw==", + "dev": true, + "requires": { + "ansi-fragments": "^0.2.1", + "yargs": "^12.0.5" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "longest-streak": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.2.tgz", + "integrity": "sha512-TmYTeEYxiAmSVdpbnQDXGtvYOIRsCMg89CVZzwzc2o7GFL1CjoiRPjH5ec0NFAVlAx3fVof9dX/t6KKRAo2OWA==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "macos-release": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.2.0.tgz", + "integrity": "sha512-iV2IDxZaX8dIcM7fG6cI46uNmHUxHE4yN+Z8tKHAW1TBPMZDIKHf/3L+YnOuj/FK9il14UaVdHmiQ1tsi90ltA==", + "dev": true + }, + "magic-string": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", + "integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==", + "dev": true, + "requires": { + "vlq": "^0.2.2" + } + }, + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", + "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "dev": true + } + } + }, + "make-fetch-happen": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-5.0.0.tgz", + "integrity": "sha512-nFr/vpL1Jc60etMVKeaLOqfGjMMb3tAHFVJWxHOFCFS04Zmd7kGlMxo0l1tzfhoQje0/UPnd0X8OeGUiXXnfPA==", + "dev": true, + "requires": { + "agentkeepalive": "^3.4.1", + "cacache": "^12.0.0", + "http-cache-semantics": "^3.8.1", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^2.2.1", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "node-fetch-npm": "^2.0.2", + "promise-retry": "^1.1.1", + "socks-proxy-agent": "^4.0.0", + "ssri": "^6.0.0" + }, + "dependencies": { + "bluebird": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.5.tgz", + "integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==", + "dev": true + }, + "cacache": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.2.tgz", + "integrity": "sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "chownr": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", + "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "dev": true + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "mississippi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "dev": true, + "requires": { + "tmpl": "1.0.x" + } + }, + "mamacro": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/mamacro/-/mamacro-0.0.3.tgz", + "integrity": "sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA==", + "dev": true + }, + "map-age-cleaner": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.2.tgz", + "integrity": "sha512-UN1dNocxQq44IhJyMI4TU8phc2m9BddacHRPRjKGLYaF0jqd3xLz0jS0skpAU9WgYyoR4gHtUpzytNBS385FWQ==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", + "integrity": "sha1-plzSkIepJZi4eRJXpSPgISIqwfk=", + "dev": true + }, + "map-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-values/-/map-values-1.0.1.tgz", + "integrity": "sha1-douOecAJvytk/ugG4ip7HEGQyZA=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "markdown-escapes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.2.tgz", + "integrity": "sha512-lbRZ2mE3Q9RtLjxZBZ9+IMl68DKIXaVAhwvwn9pmjnPLS0h/6kyBMgNhqi1xFJ/2yv6cSyv0jbiZavZv93JkkA==", + "dev": true + }, + "markdown-table": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-1.1.2.tgz", + "integrity": "sha512-NcWuJFHDA8V3wkDgR/j4+gZx+YQwstPgfQDV8ndUeWWzta3dnDTBxpVzqS9lkmJAuV5YX35lmyojl6HO5JXAgw==", + "dev": true + }, + "mathml-tag-names": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.0.tgz", + "integrity": "sha512-3Zs9P/0zzwTob2pdgT0CHZuMbnSUSp8MB1bddfm+HDmnFWHGT4jvEZRf+2RuPoa+cjdn/z25SEt5gFTqdhvJAg==", + "dev": true + }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "mdast-util-compact": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-compact/-/mdast-util-compact-1.0.1.tgz", + "integrity": "sha1-zbX4TitqLTEU3zO9BdnLMuPECDo=", + "dev": true, + "requires": { + "unist-util-modify-children": "^1.0.0", + "unist-util-visit": "^1.1.0" + } + }, + "mdast-util-inject": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-inject/-/mdast-util-inject-1.1.0.tgz", + "integrity": "sha1-2wa4tYW+lZotzS+H9HK6m3VvNnU=", + "dev": true, + "requires": { + "mdast-util-to-string": "^1.0.0" + } + }, + "mdast-util-to-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.0.5.tgz", + "integrity": "sha512-2qLt/DEOo5F6nc2VFScQiHPzQ0XXcabquRJxKMhKte8nt42o08HUxNDPk7tt0YPxnWjAT11I1SYi0X0iPnfI5A==", + "dev": true + }, + "mdn-data": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-1.1.4.tgz", + "integrity": "sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA==", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "memize": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/memize/-/memize-1.0.5.tgz", + "integrity": "sha512-Dm8Jhb5kiC4+ynYsVR4QDXKt+o2dfqGuY4hE2x+XlXZkdndlT80bJxfcMv5QGp/FCy6MhG7f5ElpmKPFKOSEpg==" + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "meow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", + "integrity": "sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0", + "yargs-parser": "^10.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "merge-deep": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.2.tgz", + "integrity": "sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "dependencies": { + "clone-deep": { + "version": "0.2.4", + "resolved": "http://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=", + "dev": true, + "requires": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + }, + "shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha1-WQnodLp3EG1zrEFM/sH/yofZcGA=", + "dev": true, + "requires": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "dependencies": { + "kind-of": { + "version": "2.0.1", + "resolved": "http://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", + "dev": true, + "requires": { + "is-buffer": "^1.0.2" + } + }, + "lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha1-f+3fLctu23fRHvHRF6tf/fCrG2U=", + "dev": true + } + } + } + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, + "merge-source-map": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", + "integrity": "sha1-pd5GU42uhNQRTMXqArR3KmNGcB8=", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "merge-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-1.0.1.tgz", + "integrity": "sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "merge2": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.3.tgz", + "integrity": "sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "metro": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.54.1.tgz", + "integrity": "sha512-6ODPT4mEo4FCpbExRNnQAcZmf1VeNvYOTMj2Na03FjGqhNODHhI2U/wF/Ul5gqTyJ2dVdkXeyvKW3gl/LrnJRg==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "@babel/generator": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/plugin-external-helpers": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "absolute-path": "^0.0.0", + "async": "^2.4.0", + "babel-preset-fbjs": "^3.1.2", + "buffer-crc32": "^0.2.13", + "chalk": "^2.4.1", + "concat-stream": "^1.6.0", + "connect": "^3.6.5", + "debug": "^2.2.0", + "denodeify": "^1.2.1", + "eventemitter3": "^3.0.0", + "fbjs": "^1.0.0", + "fs-extra": "^1.0.0", + "graceful-fs": "^4.1.3", + "image-size": "^0.6.0", + "invariant": "^2.2.4", + "jest-haste-map": "^24.7.1", + "jest-worker": "^24.6.0", + "json-stable-stringify": "^1.0.1", + "lodash.throttle": "^4.1.1", + "merge-stream": "^1.0.1", + "metro-babel-register": "0.54.1", + "metro-babel-transformer": "0.54.1", + "metro-cache": "0.54.1", + "metro-config": "0.54.1", + "metro-core": "0.54.1", + "metro-inspector-proxy": "0.54.1", + "metro-minify-uglify": "0.54.1", + "metro-react-native-babel-preset": "0.54.1", + "metro-resolver": "0.54.1", + "metro-source-map": "0.54.1", + "mime-types": "2.1.11", + "mkdirp": "^0.5.1", + "node-fetch": "^2.2.0", + "nullthrows": "^1.1.0", + "react-transform-hmr": "^1.0.4", + "resolve": "^1.5.0", + "rimraf": "^2.5.4", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "temp": "0.8.3", + "throat": "^4.1.0", + "wordwrap": "^1.0.0", + "write-file-atomic": "^1.2.0", + "ws": "^1.1.5", + "xpipe": "^1.0.5", + "yargs": "^9.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "fbjs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-1.0.0.tgz", + "integrity": "sha512-MUgcMEJaFhCaF1QtWGnmq9ZDRAzECTCRAF7O6UZIlAlkTs1SasiX9aP0Iw7wfD2mJ7wDTNfg2w7u5fSCwJk1OA==", + "dev": true, + "requires": { + "core-js": "^2.4.1", + "fbjs-css-vars": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "fs-extra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "metro-react-native-babel-preset": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.54.1.tgz", + "integrity": "sha512-Hfr32+u5yYl3qhYQJU8NQ26g4kQlc3yFMg7keVR/3H8rwBIbFqXgsKt8oe0dOrv7WvrMqBHhDtVdU9ls3sSq8g==", + "dev": true, + "requires": { + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-export-default-from": "^7.0.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/plugin-syntax-export-default-from": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.0.0", + "@babel/plugin-transform-block-scoping": "^7.0.0", + "@babel/plugin-transform-classes": "^7.0.0", + "@babel/plugin-transform-computed-properties": "^7.0.0", + "@babel/plugin-transform-destructuring": "^7.0.0", + "@babel/plugin-transform-exponentiation-operator": "^7.0.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0", + "@babel/plugin-transform-for-of": "^7.0.0", + "@babel/plugin-transform-function-name": "^7.0.0", + "@babel/plugin-transform-literals": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/plugin-transform-object-assign": "^7.0.0", + "@babel/plugin-transform-parameters": "^7.0.0", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx-source": "^7.0.0", + "@babel/plugin-transform-regenerator": "^7.0.0", + "@babel/plugin-transform-runtime": "^7.0.0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0", + "@babel/plugin-transform-spread": "^7.0.0", + "@babel/plugin-transform-sticky-regex": "^7.0.0", + "@babel/plugin-transform-template-literals": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.0.0", + "@babel/plugin-transform-unicode-regex": "^7.0.0", + "@babel/template": "^7.0.0", + "metro-babel7-plugin-react-transform": "0.54.1", + "react-transform-hmr": "^1.0.4" + } + }, + "mime-db": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz", + "integrity": "sha1-oxtAcK2uon1zLqMzdApk0OyaZlk=", + "dev": true + }, + "mime-types": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz", + "integrity": "sha1-wlnEcb2oCKhdbNGTtDCl+uRHOzw=", + "dev": true, + "requires": { + "mime-db": "~1.23.0" + } + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "write-file-atomic": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, + "ws": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.5.tgz", + "integrity": "sha512-o3KqipXNUdS7wpQzBHSe180lBGO60SoK0yVo3CYJgb2MkobuWuBX6dhkYP5ORCLd55y+SaflMOV5fqAB53ux4w==", + "dev": true, + "requires": { + "options": ">=0.0.5", + "ultron": "1.0.x" + } + }, + "yargs": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-9.0.1.tgz", + "integrity": "sha1-UqzCP+7Kw0BCB47njAwAf1CF20w=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "metro-babel-register": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-babel-register/-/metro-babel-register-0.54.1.tgz", + "integrity": "sha512-j3VydgncUG8HP6AZala6GTIt3V01nptodnnOke3JMYLqgk8EJ1LOVOdotK9pXi80o7EmmNKFs/LyyH8z+uAJzQ==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.0.0", + "@babel/plugin-transform-async-to-generator": "^7.0.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/register": "^7.0.0", + "core-js": "^2.2.2", + "escape-string-regexp": "^1.0.5" + }, + "dependencies": { + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "dev": true + } + } + }, + "metro-babel-transformer": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.54.1.tgz", + "integrity": "sha512-2aiAnuYBdcLV1VINb8ENAA4keIaJIepHgR9+iRvIde+9GSjKnexqx4nNmJN392285gRDp1fVZ7uY0uQawK/A5g==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0" + } + }, + "metro-babel7-plugin-react-transform": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-babel7-plugin-react-transform/-/metro-babel7-plugin-react-transform-0.54.1.tgz", + "integrity": "sha512-jWm5myuMoZAOhoPsa8ItfDxdTcOzKhTTzzhFlbZnRamE7i9qybeMdrZt8KHQpF7i2p/mKzE9Yhf4ouOz5K/jHg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0" + } + }, + "metro-cache": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.54.1.tgz", + "integrity": "sha512-RxCFoNcANHXZYi4MIQNnqh68gUnC3bMpzCFJY5pBoqqdrkkn8ibYglBweA0/DW7hx1OZTJWelwS1Dp8xxmE2CA==", + "dev": true, + "requires": { + "jest-serializer": "^24.4.0", + "metro-core": "0.54.1", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4" + } + }, + "metro-config": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.54.1.tgz", + "integrity": "sha512-FpxrA+63rGkPGvGI653dvuSreJzU+eOTILItVnnhmqwn2SAK5V00N/qGTOIJe2YIuWEFXwCzw9lXmANrXbwuGg==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.5", + "jest-validate": "^24.7.0", + "metro": "0.54.1", + "metro-cache": "0.54.1", + "metro-core": "0.54.1", + "pretty-format": "^24.7.0" + } + }, + "metro-core": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.54.1.tgz", + "integrity": "sha512-8oz3Ck7QFBzW9dG9tKFhrXHKPu2Ajx3R7eatf61Gl6Jf/tF7PNouv3wHxPsJW3oXDFiwKLszd89+OgleTGkB5g==", + "dev": true, + "requires": { + "jest-haste-map": "^24.7.1", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.54.1", + "wordwrap": "^1.0.0" + } + }, + "metro-inspector-proxy": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-inspector-proxy/-/metro-inspector-proxy-0.54.1.tgz", + "integrity": "sha512-sf6kNu7PgFW6U+hU7YGZfbAUKAPVvCJhY8YVu/A1RMKH9nNULrCo+jlWh0gWgmFfWRQiAPCElevROg+5somk8A==", + "dev": true, + "requires": { + "connect": "^3.6.5", + "debug": "^2.2.0", + "rxjs": "^5.4.3", + "ws": "^1.1.5", + "yargs": "^9.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "rxjs": { + "version": "5.5.12", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.12.tgz", + "integrity": "sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw==", + "dev": true, + "requires": { + "symbol-observable": "1.0.1" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "symbol-observable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=", + "dev": true + }, + "ws": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.5.tgz", + "integrity": "sha512-o3KqipXNUdS7wpQzBHSe180lBGO60SoK0yVo3CYJgb2MkobuWuBX6dhkYP5ORCLd55y+SaflMOV5fqAB53ux4w==", + "dev": true, + "requires": { + "options": ">=0.0.5", + "ultron": "1.0.x" + } + }, + "yargs": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-9.0.1.tgz", + "integrity": "sha1-UqzCP+7Kw0BCB47njAwAf1CF20w=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "metro-minify-uglify": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-minify-uglify/-/metro-minify-uglify-0.54.1.tgz", + "integrity": "sha512-z+pOPna/8IxD4OhjW6Xo1mV2EszgqqQHqBm1FdmtdF6IpWkQp33qpDBNEi9NGZTOr7pp2bvcxZnvNJdC2lrK9Q==", + "dev": true, + "requires": { + "uglify-es": "^3.1.9" + }, + "dependencies": { + "commander": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", + "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "uglify-es": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", + "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", + "dev": true, + "requires": { + "commander": "~2.13.0", + "source-map": "~0.6.1" + } + } + } + }, + "metro-react-native-babel-preset": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.55.0.tgz", + "integrity": "sha512-HUI+dEiVym8f1NYIF1grY9PdoY0d3SSS/HED2dDDvTORwndsAEWuXiUgKFOGWX18+RUAQog8obVQuBMgrr8ZBQ==", + "dev": true, + "requires": { + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-export-default-from": "^7.0.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/plugin-syntax-export-default-from": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.0.0", + "@babel/plugin-transform-block-scoping": "^7.0.0", + "@babel/plugin-transform-classes": "^7.0.0", + "@babel/plugin-transform-computed-properties": "^7.0.0", + "@babel/plugin-transform-destructuring": "^7.0.0", + "@babel/plugin-transform-exponentiation-operator": "^7.0.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0", + "@babel/plugin-transform-for-of": "^7.0.0", + "@babel/plugin-transform-function-name": "^7.0.0", + "@babel/plugin-transform-literals": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/plugin-transform-object-assign": "^7.0.0", + "@babel/plugin-transform-parameters": "^7.0.0", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx-source": "^7.0.0", + "@babel/plugin-transform-regenerator": "^7.0.0", + "@babel/plugin-transform-runtime": "^7.0.0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0", + "@babel/plugin-transform-spread": "^7.0.0", + "@babel/plugin-transform-sticky-regex": "^7.0.0", + "@babel/plugin-transform-template-literals": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.0.0", + "@babel/plugin-transform-unicode-regex": "^7.0.0", + "@babel/template": "^7.0.0", + "react-refresh": "^0.2.0" + } + }, + "metro-react-native-babel-transformer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.55.0.tgz", + "integrity": "sha512-K5dJh/HXkebKnSgJJ8XeWliwJFmit1u7CnswbhoYd0XTQQIYj6HV+neN/FzJV5tvkFkQC3asH+5xAW92HE6uRg==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "babel-preset-fbjs": "^3.1.2", + "metro-babel-transformer": "0.55.0", + "metro-react-native-babel-preset": "0.55.0", + "metro-source-map": "0.55.0" + }, + "dependencies": { + "metro-babel-transformer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.55.0.tgz", + "integrity": "sha512-eKslZokx7g0xKinJztOGELlR5N3F9oKVN5Eb9srwgFlfldY2VXN1BIYj8EBKgCA7JWgK4Rqo18/r+u+ktXzlPA==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "metro-source-map": "0.55.0" + } + }, + "metro-source-map": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.55.0.tgz", + "integrity": "sha512-HZODA0KPl5onJNGIztfTHHWurR2nL6Je/X8wwj+bL4ZBB/hSMVeDk7rWReCAvO3twVz7Ztp8Si0jfMmmH4Ruuw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "invariant": "^2.2.4", + "metro-symbolicate": "0.55.0", + "ob1": "0.55.0", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + } + }, + "vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "dev": true + } + } + }, + "metro-resolver": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.54.1.tgz", + "integrity": "sha512-Byv1LIawYAASy9CFRwzrncYnqaFGLe8vpw178EtzStqP05Hu6hXSqkNTrfoXa+3V9bPFGCrVzFx2NY3gFp2btg==", + "dev": true, + "requires": { + "absolute-path": "^0.0.0" + } + }, + "metro-source-map": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.54.1.tgz", + "integrity": "sha512-E9iSYMSUSq5qYi1R2hTQtxH4Mxjzfgr/jaSmQIWi7h3fG2P1qOZNNSzeaeUeTK+s2N/ksVlkcL5kMikol8CDrQ==", + "dev": true, + "requires": { + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "source-map": "^0.5.6" + } + }, + "metro-symbolicate": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.55.0.tgz", + "integrity": "sha512-3r3Gpv5L4U7rBGpIqw5S1nun5MelfUMLRiScJsPRGZVTX3WY1w+zpaQKlWBi5yuHf5dMQ+ZUVbhb02IdrfJ2Fg==", + "dev": true, + "requires": { + "metro-source-map": "0.55.0", + "source-map": "^0.5.6", + "through2": "^2.0.1", + "vlq": "^1.0.0" + }, + "dependencies": { + "metro-source-map": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.55.0.tgz", + "integrity": "sha512-HZODA0KPl5onJNGIztfTHHWurR2nL6Je/X8wwj+bL4ZBB/hSMVeDk7rWReCAvO3twVz7Ztp8Si0jfMmmH4Ruuw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "invariant": "^2.2.4", + "metro-symbolicate": "0.55.0", + "ob1": "0.55.0", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + } + }, + "vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "dev": true + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, + "mime": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz", + "integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==", + "dev": true + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "requires": { + "mime-db": "~1.33.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "dev": true, + "requires": { + "dom-walk": "^0.1.0" + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "minimist-options": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", + "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0" + } + }, + "minipass": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + }, + "dependencies": { + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "minizlib": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", + "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", + "dev": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mississippi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-2.0.0.tgz", + "integrity": "sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^2.0.1", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=", + "dev": true, + "requires": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "dependencies": { + "for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=", + "dev": true + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "mkdirp-promise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mkdirp-promise/-/mkdirp-promise-5.0.1.tgz", + "integrity": "sha1-6bj2jlUsaKnBcTuEiD96HdA5uKE=", + "dev": true, + "requires": { + "mkdirp": "*" + } + }, + "mkpath": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/mkpath/-/mkpath-0.1.0.tgz", + "integrity": "sha1-dVSm+Nhxg0zJe1RisSLEwSTW3pE=", + "dev": true + }, + "modify-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", + "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", + "dev": true + }, + "moment": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz", + "integrity": "sha512-shJkRTSebXvsVqk56I+lkb2latjBs8I+pc2TzWc545y2iFnSjm7Wg0QMh+ZWcdSLQyGEau5jI8ocnmkyTgr9YQ==" + }, + "moment-timezone": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.16.tgz", + "integrity": "sha512-4d1l92plNNqnMkqI/7boWNVXJvwGL2WyByl1Hxp3h/ao3HZiAqaoQY+6KBkYdiN5QtNDpndq+58ozl8W4GVoNw==", + "requires": { + "moment": ">= 2.9.0" + } + }, + "moo": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", + "integrity": "sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw==", + "dev": true + }, + "morgan": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", + "integrity": "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA==", + "dev": true, + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.2", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "mousetrap": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.2.tgz", + "integrity": "sha512-jDjhi7wlHwdO6q6DS7YRmSHcuI+RVxadBkLt3KHrhd3C2b+w5pKefg3oj5beTcHZyVFA9Aksf+yEE1y5jxUjVA==" + }, + "move-concurrently": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", + "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "multimatch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-3.0.0.tgz", + "integrity": "sha512-22foS/gqQfANZ3o+W7ST2x25ueHDVNWl/b9OlGcLpy/iKxjCpvcNCM51YCenUi7Mt/jAjjqv8JwZRs8YP5sRjA==", + "dev": true, + "requires": { + "array-differ": "^2.0.3", + "array-union": "^1.0.2", + "arrify": "^1.0.1", + "minimatch": "^3.0.4" + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nan": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "nearley": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.15.1.tgz", + "integrity": "sha512-8IUY/rUrKz2mIynUGh8k+tul1awMKEjeHHC5G3FHvvyAW6oq4mQfNp2c0BMea+sYZJvYcrrM6GmZVIle/GRXGw==", + "dev": true, + "requires": { + "moo": "^0.4.3", + "nomnom": "~1.6.2", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6", + "semver": "^5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", + "dev": true + }, + "neo-async": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.5.1.tgz", + "integrity": "sha512-3KL3fvuRkZ7s4IFOMfztb7zJp3QaVWnBeGoJlgB38XnCRPj/0tLzzLG5IB8NYOHbJ8g8UGrgZv44GLDk6CxTxA==", + "dev": true + }, + "nice-try": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.4.tgz", + "integrity": "sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==", + "dev": true + }, + "node-addon-api": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.6.2.tgz", + "integrity": "sha512-479Bjw9nTE5DdBSZZWprFryHGjUaQC31y1wHo19We/k0BZlrmhqQitWoUL0cD8+scljCbIUL+E58oRDEakdGGA==", + "dev": true + }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + }, + "node-fetch-npm": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-fetch-npm/-/node-fetch-npm-2.0.2.tgz", + "integrity": "sha512-nJIxm1QmAj4v3nfCvEeCrYSoVwXyxLnaPBK5W1W5DGEJwjlKuC2VEUycGw5oxk+4zZahRrB84PUJJgEmhFTDFw==", + "dev": true, + "requires": { + "encoding": "^0.1.11", + "json-parse-better-errors": "^1.0.0", + "safe-buffer": "^5.1.1" + } + }, + "node-forge": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", + "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==", + "dev": true + }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "dev": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", + "dev": true + } + } + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, + "node-libs-browser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.0.tgz", + "integrity": "sha512-5MQunG/oyOaBdttrL40dA7bUfPORLRWMUJLQtMg7nluxUvk5XwnLdL9twQHFAjRx/y7mIMkLKT9++qPbbk6BZA==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.0", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "0.0.4" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, + "node-notifier": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.3.0.tgz", + "integrity": "sha512-AhENzCSGZnZJgBARsUjnQ7DnZbzyP+HxlVXuD0xqAnvL8q+OqtSX7lGg9e8nHzwXkMMXNdVeqq4E2M3EUAqX6Q==", + "dev": true, + "requires": { + "growly": "^1.3.0", + "semver": "^5.5.0", + "shellwords": "^0.1.1", + "which": "^1.3.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "node-sass": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz", + "integrity": "sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==", + "dev": true, + "requires": { + "async-foreach": "^0.1.3", + "chalk": "^1.1.1", + "cross-spawn": "^3.0.0", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "in-publish": "^2.0.0", + "lodash": "^4.17.15", + "meow": "^3.7.0", + "mkdirp": "^0.5.1", + "nan": "^2.13.2", + "node-gyp": "^3.8.0", + "npmlog": "^4.0.0", + "request": "^2.88.0", + "sass-graph": "2.2.5", + "stdout-stream": "^1.4.0", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true + }, + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha512-eZ+m1WNhSZutOa/uRblAc9Ut5MQfukFrFMtPSm3bZCA888NmMd5AWXWdgRZ80zd+pTk1P2JrGjg9pUPTvl2PWQ==", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "dev": true + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==", + "dev": true + } + } + }, + "node-watch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.0.tgz", + "integrity": "sha512-XAgTL05z75ptd7JSVejH1a2Dm1zmXYhuDr9l230Qk6Z7/7GPcnAs/UyJJ4ggsXSvWil8iOzwQLW0zuGUvHpG8g==", + "dev": true + }, + "nomnom": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz", + "integrity": "sha1-hKZqJgF0QI/Ft3oY+IjszET7aXE=", + "dev": true, + "requires": { + "colors": "0.5.x", + "underscore": "~1.4.4" + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-html-whitespace": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/normalize-html-whitespace/-/normalize-html-whitespace-0.2.0.tgz", + "integrity": "sha1-EBci9kI1Ucdc24+dEE/4UNrx4Q4=", + "dev": true + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "normalize-selector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz", + "integrity": "sha1-0LFF62kRicY6eNIB3E/bEpPvDAM=", + "dev": true + }, + "normalize-url": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz", + "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==", + "dev": true + }, + "npm-bundled": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", + "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", + "dev": true + }, + "npm-lifecycle": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/npm-lifecycle/-/npm-lifecycle-3.1.2.tgz", + "integrity": "sha512-nhfOcoTHrW1lJJlM2o77vTE2RWR4YOVyj7YzmY0y5itsMjEuoJHteio/ez0BliENEPsNxIUQgwhyEW9dShj3Ww==", + "dev": true, + "requires": { + "byline": "^5.0.0", + "graceful-fs": "^4.1.15", + "node-gyp": "^5.0.2", + "resolve-from": "^4.0.0", + "slide": "^1.1.6", + "uid-number": "0.0.6", + "umask": "^1.1.0", + "which": "^1.3.1" + }, + "dependencies": { + "chownr": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", + "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "node-gyp": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-5.0.3.tgz", + "integrity": "sha512-z/JdtkFGUm0QaQUusvloyYuGDub3nUbOo5de1Fz57cM++osBTvQatBUSTlF1k/w8vFHPxxXW6zxGvkxXSpaBkQ==", + "dev": true, + "requires": { + "env-paths": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^4.4.8", + "which": "1" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true + }, + "tar": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz", + "integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.5", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, + "npm-package-arg": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.1.0.tgz", + "integrity": "sha512-zYbhP2k9DbJhA0Z3HKUePUgdB1x7MfIfKssC+WLPFMKTBZKpZh5m13PgexJjCq6KW7j17r0jHWcCpxEqnnncSA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.6.0", + "osenv": "^0.1.5", + "semver": "^5.5.0", + "validate-npm-package-name": "^3.0.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "npm-package-json-lint": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/npm-package-json-lint/-/npm-package-json-lint-3.6.0.tgz", + "integrity": "sha512-N1y3r0l0oN7mYnMfRzZvYF8+NvjIx+zkskRn3J7ofipJKGH4RDDKdEGP/mV1Crf5W8uUo3201VhJe04Q+v9erw==", + "dev": true, + "requires": { + "ajv": "^6.9.2", + "chalk": "^2.4.2", + "glob": "^7.1.3", + "ignore": "^5.0.5", + "is-path-inside": "^2.0.0", + "is-plain-obj": "^1.1.0", + "is-resolvable": "^1.1.0", + "log-symbols": "^2.2.0", + "meow": "^5.0.0", + "plur": "^3.0.1", + "semver": "^5.6.0", + "strip-json-comments": "^2.0.1", + "validator": "^10.11.0" + }, + "dependencies": { + "ajv": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.9.2.tgz", + "integrity": "sha512-4UFy0/LgDo7Oa/+wOAlj44tp9K78u38E5/359eSrqEp1Z5PdVfimCcs7SluXMP755RUQu6d2b4AvF0R1C9RZjg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "ignore": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.0.5.tgz", + "integrity": "sha512-kOC8IUb8HSDMVcYrDVezCxpJkzSQWTAzf3olpKM6o9rM5zpojx23O0Fl8Wr4+qJ6ZbPEHqf1fdwev/DS7v7pmA==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "semver": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", + "dev": true + } + } + }, + "npm-packlist": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.4.tgz", + "integrity": "sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==", + "dev": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npm-pick-manifest": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-2.2.3.tgz", + "integrity": "sha512-+IluBC5K201+gRU85vFlUwX3PFShZAbAgDNp2ewJdWMVSppdo/Zih0ul2Ecky/X7b51J7LrrUAP+XOmOCvYZqA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1", + "npm-package-arg": "^6.0.0", + "semver": "^5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "requires": { + "path-key": "^2.0.0" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "nth-check": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz", + "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "dev": true + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "ob1": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.55.0.tgz", + "integrity": "sha512-pfyiMVsUItl8WiRKMT15eCi662pCRAuYTq2+V3UpE+PpFErJI/TvRh/M/l/9TaLlbFr7krJ7gdl+FXJNcybmvw==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-filter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/object-filter/-/object-filter-1.0.2.tgz", + "integrity": "sha1-rwt5f/6+r4pSxmN87b6IFs/sG8g=", + "dev": true + }, + "object-inspect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", + "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", + "dev": true + }, + "object-is": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", + "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=" + }, + "object-keys": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", + "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==" + }, + "object-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/object-values/-/object-values-1.0.0.tgz", + "integrity": "sha1-cq+DljARnluYw7AruMJ+MjcVgQU=", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.entries": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.0.4.tgz", + "integrity": "sha1-G/mk3SKI9bM/Opk9JXZh8F0WGl8=", + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.6.1", + "function-bind": "^1.1.0", + "has": "^1.0.1" + } + }, + "object.entries-ponyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.entries-ponyfill/-/object.entries-ponyfill-1.0.1.tgz", + "integrity": "sha1-Kavfd8v70mVm3RqiTp2I9lQz0lY=", + "dev": true + }, + "object.fromentries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.0.tgz", + "integrity": "sha512-9iLiI6H083uiqUuvzyY6qrlmc/Gz8hLQFOcb/Ri/0xXFkSNS3ctV+CbE6yM2+AnkYfOB3dGjdzC0wrMLIhQICA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.11.0", + "function-bind": "^1.1.1", + "has": "^1.0.1" + } + }, + "object.getownpropertydescriptors": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", + "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "object.values": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.0.4.tgz", + "integrity": "sha1-5STaCbT2b/Bd9FdUbscqyZ8TBpo=", + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.6.1", + "function-bind": "^1.1.0", + "has": "^1.0.1" + } + }, + "octokit-pagination-methods": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz", + "integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "opencollective-postinstall": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", + "integrity": "sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==", + "dev": true + }, + "opener": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.1.tgz", + "integrity": "sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA==", + "dev": true + }, + "opn": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", + "integrity": "sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=", + "dev": true + }, + "ora": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-2.1.0.tgz", + "integrity": "sha512-hNNlAd3gfv/iPmsNxYoAPLvxg7HuPozww7fFonMZvL84tP6Ox5igfk5j/+a9rtJJwqMgKK+JgWsAQik5o0HTLA==", + "dev": true, + "requires": { + "chalk": "^2.3.1", + "cli-cursor": "^2.1.0", + "cli-spinners": "^1.1.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^4.0.0", + "wcwidth": "^1.0.1" + } + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "os-name": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", + "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", + "dev": true, + "requires": { + "macos-release": "^2.2.0", + "windows-release": "^3.1.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, + "p-each-series": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-1.0.0.tgz", + "integrity": "sha1-kw89Et0fUOdDRFeiLNbwSsatf3E=", + "dev": true, + "requires": { + "p-reduce": "^1.0.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" + }, + "p-is-promise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-map-series": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-map-series/-/p-map-series-1.0.0.tgz", + "integrity": "sha1-v5j+V1cFZYqeE1G++4WuTB8Hvco=", + "dev": true, + "requires": { + "p-reduce": "^1.0.0" + } + }, + "p-pipe": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-pipe/-/p-pipe-1.2.0.tgz", + "integrity": "sha1-SxoROZoRUgpneQ7loMHViB1r7+k=", + "dev": true + }, + "p-queue": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-4.0.0.tgz", + "integrity": "sha512-3cRXXn3/O0o3+eVmUroJPSj/esxoEFIm0ZOno/T+NzG/VZgPOqQ8WKmlNqubSEpZmCIngEy34unkHGg83ZIBmg==", + "dev": true, + "requires": { + "eventemitter3": "^3.1.0" + } + }, + "p-reduce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz", + "integrity": "sha1-GMKw3ZNqRpClKfgjH1ig/bakffo=", + "dev": true + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" + }, + "p-waterfall": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-waterfall/-/p-waterfall-1.0.0.tgz", + "integrity": "sha1-ftlLPOszMngjU69qrhGqn8I1uwA=", + "dev": true, + "requires": { + "p-reduce": "^1.0.0" + } + }, + "pako": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.8.tgz", + "integrity": "sha512-6i0HVbUfcKaTv+EG8ZTr75az7GFXcLYk9UyLEg7Notv/Ma+z/UG3TCoz6GiNeOrn1E/e63I0X/Hpw18jHOTUnA==", + "dev": true + }, + "parallel-transform": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", + "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "dev": true, + "requires": { + "cyclist": "~0.2.2", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" + } + }, + "parcel-bundler": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/parcel-bundler/-/parcel-bundler-1.12.3.tgz", + "integrity": "sha512-8bq6lj0hhQeGxD9f9xEkFMXQ3d8TIlf2+isKxoi9bciB0KVEILRGllaPkUgp++5t0anToBh9+tG6ZyInXOC1/A==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0 <7.4.0", + "@babel/core": "^7.0.0 <7.4.0", + "@babel/generator": "^7.0.0 <7.4.0", + "@babel/parser": "^7.0.0 <7.4.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0 <7.4.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0 <7.4.0", + "@babel/plugin-transform-react-jsx": "^7.0.0 <7.4.0", + "@babel/preset-env": "^7.0.0 <7.4.0", + "@babel/runtime": "^7.0.0 <7.4.0", + "@babel/template": "^7.0.0 <7.4.0", + "@babel/traverse": "^7.0.0 <7.4.0", + "@babel/types": "^7.0.0 <7.4.0", + "@iarna/toml": "^2.2.0", + "@parcel/fs": "^1.11.0", + "@parcel/logger": "^1.11.0", + "@parcel/utils": "^1.11.0", + "@parcel/watcher": "^1.12.0", + "@parcel/workers": "^1.11.0", + "ansi-to-html": "^0.6.4", + "babylon-walk": "^1.0.2", + "browserslist": "^4.1.0", + "chalk": "^2.1.0", + "clone": "^2.1.1", + "command-exists": "^1.2.6", + "commander": "^2.11.0", + "cross-spawn": "^6.0.4", + "css-modules-loader-core": "^1.1.0", + "cssnano": "^4.0.0", + "deasync": "^0.1.14", + "dotenv": "^5.0.0", + "dotenv-expand": "^4.2.0", + "fast-glob": "^2.2.2", + "filesize": "^3.6.0", + "get-port": "^3.2.0", + "htmlnano": "^0.2.2", + "is-glob": "^4.0.0", + "is-url": "^1.2.2", + "js-yaml": "^3.10.0", + "json5": "^1.0.1", + "micromatch": "^3.0.4", + "mkdirp": "^0.5.1", + "node-forge": "^0.7.1", + "node-libs-browser": "^2.0.0", + "opn": "^5.1.0", + "postcss": "^7.0.11", + "postcss-value-parser": "^3.3.1", + "posthtml": "^0.11.2", + "posthtml-parser": "^0.4.0", + "posthtml-render": "^1.1.3", + "resolve": "^1.4.0", + "semver": "^5.4.1", + "serialize-to-js": "^1.1.1", + "serve-static": "^1.12.4", + "source-map": "0.6.1", + "terser": "^3.7.3", + "v8-compile-cache": "^2.0.0", + "ws": "^5.1.1" + }, + "dependencies": { + "@babel/core": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.3.4.tgz", + "integrity": "sha512-jRsuseXBo9pN197KnDwhhaaBzyZr2oIcLHHTt2oDdQrej5Qp57dCCJafWx5ivU8/alEYDpssYqv1MUqcxwQlrA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.3.4", + "@babel/helpers": "^7.2.0", + "@babel/parser": "^7.3.4", + "@babel/template": "^7.2.2", + "@babel/traverse": "^7.3.4", + "@babel/types": "^7.3.4", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.11", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "json5": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", + "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.3.4.tgz", + "integrity": "sha512-8EXhHRFqlVVWXPezBW5keTiQi/rJMQTg/Y9uVCEZ0CAF3PKtCCaVRnp64Ii1ujhkoDhhF1fVsImoN4yJ2uz4Wg==", + "dev": true, + "requires": { + "@babel/types": "^7.3.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.11", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.3.4.tgz", + "integrity": "sha512-tXZCqWtlOOP4wgCp6RjRvLmfuhnqTLy9VHwRochJBCP2nDm27JnnuFEnXFASVyQNHk36jD1tAammsCEEqgscIQ==", + "dev": true + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.2.0.tgz", + "integrity": "sha512-V6y0uaUQrQPXUrmj+hgnks8va2L0zcZymeU7TtWEgdRLNkceafKXEduv7QzgQAE4lT+suwooG9dC7LFhdRAbVQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-simple-access": "^7.1.0" + } + }, + "@babel/preset-env": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.3.4.tgz", + "integrity": "sha512-2mwqfYMK8weA0g0uBKOt4FE3iEodiHy9/CW0b+nWXcbL+pGzLx8ESYc+j9IIxr6LTDHWKgPm71i9smo02bw+gA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-json-strings": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.3.4", + "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.2.0", + "@babel/plugin-syntax-async-generators": "^7.2.0", + "@babel/plugin-syntax-json-strings": "^7.2.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.3.4", + "@babel/plugin-transform-block-scoped-functions": "^7.2.0", + "@babel/plugin-transform-block-scoping": "^7.3.4", + "@babel/plugin-transform-classes": "^7.3.4", + "@babel/plugin-transform-computed-properties": "^7.2.0", + "@babel/plugin-transform-destructuring": "^7.2.0", + "@babel/plugin-transform-dotall-regex": "^7.2.0", + "@babel/plugin-transform-duplicate-keys": "^7.2.0", + "@babel/plugin-transform-exponentiation-operator": "^7.2.0", + "@babel/plugin-transform-for-of": "^7.2.0", + "@babel/plugin-transform-function-name": "^7.2.0", + "@babel/plugin-transform-literals": "^7.2.0", + "@babel/plugin-transform-modules-amd": "^7.2.0", + "@babel/plugin-transform-modules-commonjs": "^7.2.0", + "@babel/plugin-transform-modules-systemjs": "^7.3.4", + "@babel/plugin-transform-modules-umd": "^7.2.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.3.0", + "@babel/plugin-transform-new-target": "^7.0.0", + "@babel/plugin-transform-object-super": "^7.2.0", + "@babel/plugin-transform-parameters": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.3.4", + "@babel/plugin-transform-shorthand-properties": "^7.2.0", + "@babel/plugin-transform-spread": "^7.2.0", + "@babel/plugin-transform-sticky-regex": "^7.2.0", + "@babel/plugin-transform-template-literals": "^7.2.0", + "@babel/plugin-transform-typeof-symbol": "^7.2.0", + "@babel/plugin-transform-unicode-regex": "^7.2.0", + "browserslist": "^4.3.4", + "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", + "semver": "^5.3.0" + } + }, + "@babel/runtime": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.3.4.tgz", + "integrity": "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.12.0" + } + }, + "@babel/template": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.2.2.tgz", + "integrity": "sha512-zRL0IMM02AUDwghf5LMSSDEz7sBCO2YnNmpg3uWTZj/v1rcG2BmQUvaGU8GhU8BvfMh1k2KIAYZ7Ji9KXPUg7g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.2.2", + "@babel/types": "^7.2.2" + } + }, + "@babel/traverse": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.3.4.tgz", + "integrity": "sha512-TvTHKp6471OYEcE/91uWmhR6PrrYywQntCHSaZ8CM8Vmp+pjAusal4nGB2WCCQd0rvI7nOMKn9GnbcvTUz3/ZQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.3.4", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.0.0", + "@babel/parser": "^7.3.4", + "@babel/types": "^7.3.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.11" + } + }, + "@babel/types": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.3.4.tgz", + "integrity": "sha512-WEkp8MsLftM7O/ty580wAmZzN1nDmCACc5+jFzUt+GUFNNIi3LdRlueYz0YIlmJhlZx1QYDMZL5vdWCL0fNjFQ==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.11", + "to-fast-properties": "^2.0.0" + } + }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-asn1": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", + "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, + "parse-entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.1.2.tgz", + "integrity": "sha512-5N9lmQ7tmxfXf+hO3X6KRG6w7uYO/HL9fHalSySTdyn63C3WNvTM/1R8tn1u1larNcEbo3Slcy2bsVDQqvEpUg==", + "dev": true, + "requires": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "parse-github-repo-url": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz", + "integrity": "sha1-nn2LslKmy2ukJZUGC3v23z28H1A=", + "dev": true + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", + "dev": true + }, + "parse-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-4.0.1.tgz", + "integrity": "sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA==", + "dev": true, + "requires": { + "is-ssh": "^1.3.0", + "protocols": "^1.4.0" + } + }, + "parse-url": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-5.0.1.tgz", + "integrity": "sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg==", + "dev": true, + "requires": { + "is-ssh": "^1.3.0", + "normalize-url": "^3.3.0", + "parse-path": "^4.0.0", + "protocols": "^1.4.0" + } + }, + "parse5": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz", + "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=", + "dev": true + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "pbkdf2": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", + "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "pegjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", + "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=" + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "phpegjs": { + "version": "1.0.0-beta7", + "resolved": "https://registry.npmjs.org/phpegjs/-/phpegjs-1.0.0-beta7.tgz", + "integrity": "sha1-uLbthQGYB//Q7+ID4AKj5e2LTZQ=" + }, + "physical-cpu-count": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/physical-cpu-count/-/physical-cpu-count-2.0.0.tgz", + "integrity": "sha1-GN4vl+S/epVRrXURlCtUlverpmA=", + "dev": true + }, + "picomatch": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.0.7.tgz", + "integrity": "sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "platform": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.5.tgz", + "integrity": "sha512-TuvHS8AOIZNAlE77WUDiR4rySV/VMptyMfcfeoMgs4P8apaZM3JrnbzBiixKUv+XR6i+BXrQh8WAnjaSPFO65Q==", + "dev": true + }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, + "plist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.1.tgz", + "integrity": "sha512-GpgvHHocGRyQm74b6FWEZZVRroHKE1I0/BTjAmySaohK+cUn+hZpbqXkc3KWgW3gQYkqcQej35FohcT0FRlkRQ==", + "dev": true, + "requires": { + "base64-js": "^1.2.3", + "xmlbuilder": "^9.0.7", + "xmldom": "0.1.x" + } + }, + "plugin-error": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz", + "integrity": "sha1-O5uzM1zPAPQl4HQ34ZJ2ln2kes4=", + "dev": true, + "requires": { + "ansi-cyan": "^0.1.1", + "ansi-red": "^0.1.1", + "arr-diff": "^1.0.1", + "arr-union": "^2.0.1", + "extend-shallow": "^1.1.2" + }, + "dependencies": { + "arr-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-1.1.0.tgz", + "integrity": "sha1-aHwydYFjWI/vfeezb6vklesaOZo=", + "dev": true, + "requires": { + "arr-flatten": "^1.0.1", + "array-slice": "^0.2.3" + } + }, + "arr-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-2.1.0.tgz", + "integrity": "sha1-IPnqtexw9cfSFbEHexw5Fh0pLH0=", + "dev": true + }, + "extend-shallow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-1.1.4.tgz", + "integrity": "sha1-Gda/lN/AnXa6cR85uHLSH/TdkHE=", + "dev": true, + "requires": { + "kind-of": "^1.1.0" + } + }, + "kind-of": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ=", + "dev": true + } + } + }, + "plur": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/plur/-/plur-3.0.1.tgz", + "integrity": "sha512-lJl0ojUynAM1BZn58Pas2WT/TXeC1+bS+UqShl0x9+49AtOn7DixRXVzaC8qrDOIxNDmepKnLuMTH7NQmkX0PA==", + "dev": true, + "requires": { + "irregular-plurals": "^2.0.0" + } + }, + "pn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", + "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", + "dev": true + }, + "portfinder": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", + "integrity": "sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw==", + "dev": true, + "requires": { + "async": "^1.5.2", + "debug": "^2.2.0", + "mkdirp": "0.5.x" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.13.tgz", + "integrity": "sha512-h8SY6kQTd1wISHWjz+E6cswdhMuyBZRb16pSTv3W4zYZ3/YbyWeJdNUeOXB5IdZqE1U76OUEjjjqsC3z2f3hVg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-calc": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.1.tgz", + "integrity": "sha512-oXqx0m6tb4N3JGdmeMSc/i91KppbYsFZKdH0xMOqK8V1rJlzrKlTdokz8ozUXLVejydRN6u2IddxpcijRj2FqQ==", + "dev": true, + "requires": { + "css-unit-converter": "^1.1.1", + "postcss": "^7.0.5", + "postcss-selector-parser": "^5.0.0-rc.4", + "postcss-value-parser": "^3.3.1" + }, + "dependencies": { + "cssesc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-2.0.0.tgz", + "integrity": "sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg==", + "dev": true + }, + "postcss-selector-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz", + "integrity": "sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ==", + "dev": true, + "requires": { + "cssesc": "^2.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-color-function": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-color-function/-/postcss-color-function-4.0.1.tgz", + "integrity": "sha1-QCs/LOvD9pR+YY+2vjZU++zvZEQ=", + "dev": true, + "requires": { + "css-color-function": "~1.3.3", + "postcss": "^6.0.1", + "postcss-message-helpers": "^2.0.0", + "postcss-value-parser": "^3.3.0" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-colormin": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.3.tgz", + "integrity": "sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "color": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.0.tgz", + "integrity": "sha512-CwyopLkuRYO5ei2EpzpIh6LqJMt6Mt+jZhO5VI5f/wJLZriXQE32/SSqzmrh+QB+AZT81Cj8yv+7zwToW8ahZg==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + } + } + }, + "postcss-convert-values": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz", + "integrity": "sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-discard-comments": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz", + "integrity": "sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-duplicates": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz", + "integrity": "sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-empty": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz", + "integrity": "sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-overridden": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz", + "integrity": "sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-html": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-0.36.0.tgz", + "integrity": "sha512-HeiOxGcuwID0AFsNAL0ox3mW6MHH5cstWN1Z3Y+n6H+g12ih7LHdYxWwEA/QmrebctLjo79xz9ouK3MroHwOJw==", + "dev": true, + "requires": { + "htmlparser2": "^3.10.0" + }, + "dependencies": { + "htmlparser2": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", + "integrity": "sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ==", + "dev": true, + "requires": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.0.6" + } + }, + "readable-stream": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.1.1.tgz", + "integrity": "sha512-DkN66hPyqDhnIQ6Jcsvx9bFjhw214O4poMBcIMgPVpQvNy9a0e0Uhg5SqySyDKAmUlwt8LonTBz1ezOnM8pUdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "postcss-jsx": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/postcss-jsx/-/postcss-jsx-0.36.0.tgz", + "integrity": "sha512-/lWOSXSX5jlITCKFkuYU2WLFdrncZmjSVyNpHAunEgirZXLwI8RjU556e3Uz4mv0WVHnJA9d3JWb36lK9Yx99g==", + "dev": true, + "requires": { + "@babel/core": ">=7.1.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.2.2.tgz", + "integrity": "sha512-59vB0RWt09cAct5EIe58+NzGP4TFSD3Bz//2/ELy3ZeTeKF6VTD1AXlH8BGGbCX0PuobZBsIzO7IAI9PH67eKw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.2.2", + "@babel/helpers": "^7.2.0", + "@babel/parser": "^7.2.2", + "@babel/template": "^7.2.2", + "@babel/traverse": "^7.2.2", + "@babel/types": "^7.2.2", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.10", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.3.2.tgz", + "integrity": "sha512-f3QCuPppXxtZOEm5GWPra/uYUjmNQlu9pbAD8D/9jze4pTY83rTtB1igTBSwvkeNlC5gR24zFFkz+2WHLFQhqQ==", + "dev": true, + "requires": { + "@babel/types": "^7.3.2", + "jsesc": "^2.5.1", + "lodash": "^4.17.10", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + }, + "dependencies": { + "@babel/types": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.3.2.tgz", + "integrity": "sha512-3Y6H8xlUlpbGR+XvawiH0UXehqydTmNmEpozWcXymqwcrwYAl5KMvKtQ+TF6f6E08V6Jur7v/ykdDSF+WDEIXQ==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helpers": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.3.1.tgz", + "integrity": "sha512-Q82R3jKsVpUV99mgX50gOPCWwco9Ec5Iln/8Vyu4osNIOQgSrd9RFrQeUvmvddFNoLwMyOUWU+5ckioEKpDoGA==", + "dev": true, + "requires": { + "@babel/template": "^7.1.2", + "@babel/traverse": "^7.1.5", + "@babel/types": "^7.3.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.3.2.tgz", + "integrity": "sha512-3Y6H8xlUlpbGR+XvawiH0UXehqydTmNmEpozWcXymqwcrwYAl5KMvKtQ+TF6f6E08V6Jur7v/ykdDSF+WDEIXQ==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.2.3.tgz", + "integrity": "sha512-0LyEcVlfCoFmci8mXx8A5oIkpkOgyo8dRHtxBnK9RRBwxO2+JZPNsqtVEZQ7mJFPxnXF9lfmU24mHOPI0qnlkA==" + }, + "@babel/template": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.2.2.tgz", + "integrity": "sha512-zRL0IMM02AUDwghf5LMSSDEz7sBCO2YnNmpg3uWTZj/v1rcG2BmQUvaGU8GhU8BvfMh1k2KIAYZ7Ji9KXPUg7g==", + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.2.2", + "@babel/types": "^7.2.2" + } + }, + "@babel/types": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.2.2.tgz", + "integrity": "sha512-fKCuD6UFUMkR541eDWL+2ih/xFZBXPOg/7EQFeTluMDebfqR4jrpaCjLhkWlQS4hT6nRa2PMEgXKbRB5/H2fpg==", + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "json5": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", + "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "postcss-less": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-3.1.1.tgz", + "integrity": "sha512-yVa0hb03p7xj914Z4qDDA/PGwXYvCEfjJizWVYQvnEQr8SgJ098qejCvbCGk1dDYQpQEGKkvYHQCo66DwTocjg==", + "dev": true, + "requires": { + "postcss": "^7.0.3" + } + }, + "postcss-markdown": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/postcss-markdown/-/postcss-markdown-0.36.0.tgz", + "integrity": "sha512-rl7fs1r/LNSB2bWRhyZ+lM/0bwKv9fhl38/06gF6mKMo/NPnp55+K1dSTosSVjFZc0e1ppBlu+WT91ba0PMBfQ==", + "dev": true, + "requires": { + "remark": "^10.0.1", + "unist-util-find-all-after": "^1.0.2" + } + }, + "postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=", + "dev": true + }, + "postcss-merge-longhand": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz", + "integrity": "sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw==", + "dev": true, + "requires": { + "css-color-names": "0.0.4", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "stylehacks": "^4.0.0" + } + }, + "postcss-merge-rules": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz", + "integrity": "sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "cssnano-util-same-parent": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0", + "vendors": "^1.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-message-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz", + "integrity": "sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4=", + "dev": true + }, + "postcss-minify-font-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz", + "integrity": "sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-gradients": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz", + "integrity": "sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "is-color-stop": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-params": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz", + "integrity": "sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "browserslist": "^4.0.0", + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-minify-selectors": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz", + "integrity": "sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-modules-extract-imports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz", + "integrity": "sha1-thTJcgvmgW6u41+zpfqh26agXds=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-local-by-default": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz", + "integrity": "sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz", + "integrity": "sha1-1upkmUx5+XtipytCb75gVqGUu5A=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-values": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz", + "integrity": "sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA=", + "dev": true, + "requires": { + "icss-replace-symbols": "^1.1.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-normalize-charset": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz", + "integrity": "sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-normalize-display-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz", + "integrity": "sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-positions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz", + "integrity": "sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz", + "integrity": "sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-string": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz", + "integrity": "sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA==", + "dev": true, + "requires": { + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz", + "integrity": "sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-unicode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz", + "integrity": "sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz", + "integrity": "sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA==", + "dev": true, + "requires": { + "is-absolute-url": "^2.0.0", + "normalize-url": "^3.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-whitespace": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz", + "integrity": "sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-ordered-values": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz", + "integrity": "sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-reduce-initial": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz", + "integrity": "sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz", + "integrity": "sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-reporter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-6.0.1.tgz", + "integrity": "sha512-LpmQjfRWyabc+fRygxZjpRxfhRf9u/fdlKf4VHG4TSPbV2XNsuISzYW1KL+1aQzx53CAppa1bKG4APIB/DOXXw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "lodash": "^4.17.11", + "log-symbols": "^2.2.0", + "postcss": "^7.0.7" + } + }, + "postcss-resolve-nested-selector": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz", + "integrity": "sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4=", + "dev": true + }, + "postcss-safe-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz", + "integrity": "sha512-xZsFA3uX8MO3yAda03QrG3/Eg1LN3EPfjjf07vke/46HERLZyHrTsQ9E1r1w1W//fWEhtYNndo2hQplN2cVpCQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-sass": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/postcss-sass/-/postcss-sass-0.3.5.tgz", + "integrity": "sha512-B5z2Kob4xBxFjcufFnhQ2HqJQ2y/Zs/ic5EZbCywCkxKd756Q40cIQ/veRDwSrw1BF6+4wUgmpm0sBASqVi65A==", + "dev": true, + "requires": { + "gonzales-pe": "^4.2.3", + "postcss": "^7.0.1" + } + }, + "postcss-scss": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-2.0.0.tgz", + "integrity": "sha512-um9zdGKaDZirMm+kZFKKVsnKPF7zF7qBAtIfTSnZXD1jZ0JNZIxdB6TxQOjCnlSzLRInVl2v3YdBh/M881C4ug==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-svgo": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz", + "integrity": "sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==", + "dev": true, + "requires": { + "is-svg": "^3.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "svgo": "^1.0.0" + } + }, + "postcss-syntax": { + "version": "0.36.2", + "resolved": "https://registry.npmjs.org/postcss-syntax/-/postcss-syntax-0.36.2.tgz", + "integrity": "sha512-nBRg/i7E3SOHWxF3PpF5WnJM/jQ1YpY9000OaVXlAQj6Zp/kIqJxEDWIZ67tAd7NLuk7zqN4yqe9nc0oNAOs1w==", + "dev": true + }, + "postcss-unique-selectors": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz", + "integrity": "sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "postcss": "^7.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-value-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz", + "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU=", + "dev": true + }, + "posthtml": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.11.3.tgz", + "integrity": "sha512-quMHnDckt2DQ9lRi6bYLnuyBDnVzK+McHa8+ar4kTdYbWEo/92hREOu3h70ZirudOOp/my2b3r0m5YtxY52yrA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "posthtml-parser": "^0.3.3", + "posthtml-render": "^1.1.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "posthtml-parser": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.3.3.tgz", + "integrity": "sha512-H/Z/yXGwl49A7hYQLV1iQ3h87NE0aZ/PMZhFwhw3lKeCAN+Ti4idrHvVvh4/GX10I7u77aQw+QB4vV5/Lzvv5A==", + "dev": true, + "requires": { + "htmlparser2": "^3.9.2", + "isobject": "^2.1.0", + "object-assign": "^4.1.1" + } + } + } + }, + "posthtml-parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.4.1.tgz", + "integrity": "sha512-h7vXIQ21Ikz2w5wPClPakNP6mJeJCK6BT0GpqnQrNNABdR7/TchNlFyryL1Bz6Ww53YWCKkr6tdZuHlxY1AVdQ==", + "dev": true, + "requires": { + "htmlparser2": "^3.9.2", + "object-assign": "^4.1.1" + } + }, + "posthtml-render": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-1.1.4.tgz", + "integrity": "sha512-jL6eFIzoN3xUEvbo33OAkSDE2VIKU4JQ1wENOows1DpfnrdapR/K3Q1/fB43Mq7wQlcSgRm23nFrvoioufM7eA==", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "pretty-format": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.7.0.tgz", + "integrity": "sha512-apen5cjf/U4dj7tHetpC7UEFCvtAgnNZnBDkfPv3fokzIqyOJckAG9OlAPC1BlFALnqT/lGB2tl9EJjlK6eCsA==", + "dev": true, + "requires": { + "@jest/types": "^24.7.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", + "dev": true + }, + "promise-retry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-1.1.1.tgz", + "integrity": "sha1-ZznpaOMFHaIM5kl/srUPaRHfPW0=", + "dev": true, + "requires": { + "err-code": "^1.0.0", + "retry": "^0.10.0" + } + }, + "prompts": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.0.3.tgz", + "integrity": "sha512-H8oWEoRZpybm6NV4to9/1limhttEo13xK62pNvn2JzY0MA03p7s0OjtmhXyon3uJmxiJJVSuUwEJFFssI3eBiQ==", + "dev": true, + "requires": { + "kleur": "^3.0.2", + "sisteransi": "^1.0.0" + } + }, + "promzard": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", + "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", + "dev": true, + "requires": { + "read": "1" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "requires": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "dev": true + }, + "protocols": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.7.tgz", + "integrity": "sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg==", + "dev": true + }, + "protoduck": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/protoduck/-/protoduck-5.0.1.tgz", + "integrity": "sha512-WxoCeDCoCBY55BMvj4cAEjdVUFGRWed9ZxPlqTKYyw1nDDTQ4pqmnIMAGfJlg7Dx35uB/M+PHJPTmGOvaCaPTg==", + "dev": true, + "requires": { + "genfun": "^5.0.0" + } + }, + "proxy-addr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", + "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", + "dev": true, + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.8.0" + } + }, + "proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, + "psl": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.28.tgz", + "integrity": "sha512-+AqO1Ae+N/4r7Rvchrdm432afjT9hqJRyBN3DQv9At0tPz4hIFSGKbq64fN9dVoCow4oggIIax5/iONx0r9hZw==", + "dev": true + }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "puppeteer": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.19.0.tgz", + "integrity": "sha512-2S6E6ygpoqcECaagDbBopoSOPDv0pAZvTbnBgUY+6hq0/XDFDOLEMNlHF/SKJlzcaZ9ckiKjKDuueWI3FN/WXw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "extract-zip": "^1.6.6", + "https-proxy-agent": "^2.2.1", + "mime": "^2.0.3", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^2.6.1", + "ws": "^6.1.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "quick-lru": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", + "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", + "dev": true + }, + "quote-stream": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", + "integrity": "sha1-hJY/jJwmuULhU/7rU6rnRlK34LI=", + "dev": true, + "requires": { + "buffer-equal": "0.0.1", + "minimist": "^1.1.3", + "through2": "^2.0.0" + } + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "requires": { + "performance-now": "^2.1.0" + } + }, + "railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", + "dev": true + }, + "randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "requires": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + } + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", + "dev": true + }, + "raw-body": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", + "integrity": "sha1-HQJ8K/oRasxmI7yo8AAWVyqH1CU=", + "dev": true, + "requires": { + "bytes": "1", + "string_decoder": "0.10" + }, + "dependencies": { + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "re-resizable": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.0.0.tgz", + "integrity": "sha512-RTrnhbGgYyZ4hTc6db4JeMnRfmloEPWtuYaXZEa2PRaEC4mreWNFnZtMVsHil3z3iX+WchD+da8BLlTJBcstMA==", + "requires": { + "fast-memoize": "^2.5.1" + } + }, + "react": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.9.0.tgz", + "integrity": "sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-addons-shallow-compare": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz", + "integrity": "sha1-GYoAuR/DdiPbZKKP0XtZa6NicC8=", + "requires": { + "fbjs": "^0.8.4", + "object-assign": "^4.1.0" + } + }, + "react-autosize-textarea": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-autosize-textarea/-/react-autosize-textarea-3.0.2.tgz", + "integrity": "sha1-K2hApp9xOHGavOpaQp7PcwF2jAc=", + "requires": { + "autosize": "^4.0.0", + "line-height": "^0.3.1", + "prop-types": "^15.5.6" + } + }, + "react-dates": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/react-dates/-/react-dates-17.2.0.tgz", + "integrity": "sha512-RDlerU8DdRRrlYS0MQ7Z9igPWABGLDwz6+ykBNff67RM3Sset2TDqeuOr+R5o00Ggn5U47GeLsGcSDxlZd9cHw==", + "requires": { + "airbnb-prop-types": "^2.10.0", + "consolidated-events": "^1.1.1 || ^2.0.0", + "is-touch-device": "^1.0.1", + "lodash": "^4.1.1", + "object.assign": "^4.1.0", + "object.values": "^1.0.4", + "prop-types": "^15.6.1", + "react-addons-shallow-compare": "^15.6.2", + "react-moment-proptypes": "^1.6.0", + "react-outside-click-handler": "^1.2.0", + "react-portal": "^4.1.5", + "react-with-styles": "^3.2.0", + "react-with-styles-interface-css": "^4.0.2" + } + }, + "react-deep-force-update": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz", + "integrity": "sha512-WUSQJ4P/wWcusaH+zZmbECOk7H5N2pOIl0vzheeornkIMhu+qrNdGFm0bDZLCb0hSF0jf/kH1SgkNGfBdTc4wA==", + "dev": true + }, + "react-devtools-core": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-3.6.1.tgz", + "integrity": "sha512-I/LSX+tpeTrGKaF1wXSfJ/kP+6iaP2JfshEjW8LtQBdz6c6HhzOJtjZXhqOUrAdysuey8M1/JgPY1flSVVt8Ig==", + "dev": true, + "requires": { + "shell-quote": "^1.6.1", + "ws": "^3.3.1" + }, + "dependencies": { + "ultron": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", + "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", + "dev": true + }, + "ws": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", + "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0", + "safe-buffer": "~5.1.0", + "ultron": "~1.1.0" + } + } + } + }, + "react-dom": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.9.0.tgz", + "integrity": "sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.15.0" + } + }, + "react-is": { + "version": "16.8.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.4.tgz", + "integrity": "sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA==" + }, + "react-moment-proptypes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/react-moment-proptypes/-/react-moment-proptypes-1.6.0.tgz", + "integrity": "sha512-4h7EuhDMTzQqZ+02KUUO+AVA7PqhbD88yXB740nFpNDyDS/bj9jiPyn2rwr9sa8oDyaE1ByFN9+t5XPyPTmN6g==", + "requires": { + "moment": ">=1.6.0" + } + }, + "react-native": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.60.0.tgz", + "integrity": "sha512-Leo1MfUpQFCLchr60HCDZAk7M6Bd2yPplSDBuCrC9gUtsRO2P4nLxwrX6P+vbjF7Td2sQbcGqW2E809Oi41K0g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.0.0", + "@react-native-community/cli": "^2.0.1", + "@react-native-community/cli-platform-android": "^2.0.1", + "@react-native-community/cli-platform-ios": "^2.0.1", + "abort-controller": "^3.0.0", + "art": "^0.10.0", + "base64-js": "^1.1.2", + "connect": "^3.6.5", + "create-react-class": "^15.6.3", + "escape-string-regexp": "^1.0.5", + "event-target-shim": "^5.0.1", + "fbjs": "^1.0.0", + "fbjs-scripts": "^1.1.0", + "invariant": "^2.2.4", + "jsc-android": "245459.0.0", + "metro-babel-register": "0.54.1", + "metro-react-native-babel-transformer": "0.54.1", + "nullthrows": "^1.1.0", + "pretty-format": "^24.7.0", + "promise": "^7.1.1", + "prop-types": "^15.7.2", + "react-devtools-core": "^3.6.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.14.0", + "stacktrace-parser": "^0.1.3", + "whatwg-fetch": "^3.0.0" + }, + "dependencies": { + "@react-native-community/cli": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-2.3.0.tgz", + "integrity": "sha512-Vf11SAW1ZwSfZ3JsYiEhU1/Oq9ozKyh7Uawbg8D4IrBM8dCHSpPGhONNfzSQ6x4k9YQjFHH+bOY274/fcYtNXQ==", + "dev": true, + "requires": { + "@hapi/joi": "^15.0.3", + "@react-native-community/cli-platform-android": "^2.1.1", + "@react-native-community/cli-platform-ios": "^2.2.0", + "@react-native-community/cli-tools": "^2.0.2", + "chalk": "^1.1.1", + "command-exists": "^1.2.8", + "commander": "^2.19.0", + "compression": "^1.7.1", + "connect": "^3.6.5", + "cosmiconfig": "^5.1.0", + "deepmerge": "^3.2.0", + "envinfo": "^7.1.0", + "errorhandler": "^1.5.0", + "execa": "^1.0.0", + "fs-extra": "^7.0.1", + "glob": "^7.1.1", + "graceful-fs": "^4.1.3", + "inquirer": "^3.0.6", + "lodash": "^4.17.5", + "metro": "^0.54.1", + "metro-config": "^0.54.1", + "metro-core": "^0.54.1", + "metro-react-native-babel-transformer": "^0.54.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "morgan": "^1.9.0", + "node-notifier": "^5.2.1", + "open": "^6.2.0", + "ora": "^3.4.0", + "plist": "^3.0.0", + "semver": "^5.0.3", + "serve-static": "^1.13.1", + "shell-quote": "1.6.1", + "ws": "^1.1.0" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "cli-spinners": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.2.0.tgz", + "integrity": "sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ==", + "dev": true + }, + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "dev": true + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "deepmerge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz", + "integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "external-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "fbjs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-1.0.0.tgz", + "integrity": "sha512-MUgcMEJaFhCaF1QtWGnmq9ZDRAzECTCRAF7O6UZIlAlkTs1SasiX9aP0Iw7wfD2mJ7wDTNfg2w7u5fSCwJk1OA==", + "dev": true, + "requires": { + "core-js": "^2.4.1", + "fbjs-css-vars": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.0.4", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rx-lite": "^4.0.8", + "rx-lite-aggregates": "^4.0.8", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "metro-react-native-babel-preset": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.54.1.tgz", + "integrity": "sha512-Hfr32+u5yYl3qhYQJU8NQ26g4kQlc3yFMg7keVR/3H8rwBIbFqXgsKt8oe0dOrv7WvrMqBHhDtVdU9ls3sSq8g==", + "dev": true, + "requires": { + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-export-default-from": "^7.0.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/plugin-syntax-export-default-from": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.0.0", + "@babel/plugin-transform-block-scoping": "^7.0.0", + "@babel/plugin-transform-classes": "^7.0.0", + "@babel/plugin-transform-computed-properties": "^7.0.0", + "@babel/plugin-transform-destructuring": "^7.0.0", + "@babel/plugin-transform-exponentiation-operator": "^7.0.0", + "@babel/plugin-transform-flow-strip-types": "^7.0.0", + "@babel/plugin-transform-for-of": "^7.0.0", + "@babel/plugin-transform-function-name": "^7.0.0", + "@babel/plugin-transform-literals": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/plugin-transform-object-assign": "^7.0.0", + "@babel/plugin-transform-parameters": "^7.0.0", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx-source": "^7.0.0", + "@babel/plugin-transform-regenerator": "^7.0.0", + "@babel/plugin-transform-runtime": "^7.0.0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0", + "@babel/plugin-transform-spread": "^7.0.0", + "@babel/plugin-transform-sticky-regex": "^7.0.0", + "@babel/plugin-transform-template-literals": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.0.0", + "@babel/plugin-transform-unicode-regex": "^7.0.0", + "@babel/template": "^7.0.0", + "metro-babel7-plugin-react-transform": "0.54.1", + "react-transform-hmr": "^1.0.4" + } + }, + "metro-react-native-babel-transformer": { + "version": "0.54.1", + "resolved": "https://registry.npmjs.org/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.54.1.tgz", + "integrity": "sha512-ECw7xG91t8dk/PHdiyoC5SP1s9OQzfmJzG5m0YOZaKtHMe534qTDbncxaKfTI3CP99yti2maXFBRVj+xyvph/g==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "babel-preset-fbjs": "^3.1.2", + "metro-babel-transformer": "0.54.1", + "metro-react-native-babel-preset": "0.54.1" + } + }, + "ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "scheduler": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.14.0.tgz", + "integrity": "sha512-9CgbS06Kki2f4R9FjLSITjZo5BZxPsryiRNyL3LpvrM9WxcVmhlqAOc9E+KQbeI2nqej4JIIbOsfdL51cNb4Iw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==", + "dev": true + }, + "ws": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.5.tgz", + "integrity": "sha512-o3KqipXNUdS7wpQzBHSe180lBGO60SoK0yVo3CYJgb2MkobuWuBX6dhkYP5ORCLd55y+SaflMOV5fqAB53ux4w==", + "dev": true, + "requires": { + "options": ">=0.0.5", + "ultron": "1.0.x" + } + } + } + }, + "react-outside-click-handler": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/react-outside-click-handler/-/react-outside-click-handler-1.2.2.tgz", + "integrity": "sha512-MgCxmFARGN1VrZdwoLkER/y3So6mC/fSniXI4XcXcB+Jt05nw/k8a/R1hSoa7p414uZUZ8NfClN3eVmZm9bM5Q==", + "requires": { + "airbnb-prop-types": "^2.10.0", + "consolidated-events": "^1.1.1 || ^2.0.0", + "object.values": "^1.0.4", + "prop-types": "^15.6.1" + } + }, + "react-portal": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-4.1.5.tgz", + "integrity": "sha512-jJMy9DoVr4HRWPdO8IP/mDHP1Q972/aKkulVQeIrttOIyRNmCkR2IH7gK3HVjhzxy6M+k9TopSWN5q41wO/o6A==", + "requires": { + "prop-types": "^15.5.8" + } + }, + "react-proxy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/react-proxy/-/react-proxy-1.1.8.tgz", + "integrity": "sha1-nb/Z2SdSjDqp9ETkVYw3gwq4wmo=", + "dev": true, + "requires": { + "lodash": "^4.6.1", + "react-deep-force-update": "^1.0.0" + } + }, + "react-refresh": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.2.0.tgz", + "integrity": "sha512-ITw8t/HOFNose2yf1y9pPFSSeB9ISOq2JdHpuZvj/Qb+iSsLml8GkkHdDlURzieO7B3dFDtMrrneZLl3N5z/hg==", + "dev": true + }, + "react-spring": { + "version": "8.0.20", + "resolved": "https://registry.npmjs.org/react-spring/-/react-spring-8.0.20.tgz", + "integrity": "sha512-40ZUQ5uI5YHsoQWLPchWNcEUh6zQ6qvcVDeTI2vW10ldoCN3PvDsII9wBH2xEbMl+BQvYmHzGdfLTQxPxJWGnQ==", + "requires": { + "@babel/runtime": "^7.3.1", + "prop-types": "^15.5.8" + } + }, + "react-test-renderer": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0.tgz", + "integrity": "sha512-R62stB73qZyhrJo7wmCW9jgl/07ai+YzvouvCXIJLBkRlRqLx4j9RqcLEAfNfU3OxTGucqR2Whmn3/Aad6L3hQ==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.9.0", + "scheduler": "^0.15.0" + }, + "dependencies": { + "react-is": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", + "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==", + "dev": true + } + } + }, + "react-transform-hmr": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-transform-hmr/-/react-transform-hmr-1.0.4.tgz", + "integrity": "sha1-4aQL0Krvxy6N/Xp82gmvhQZjl7s=", + "dev": true, + "requires": { + "global": "^4.3.0", + "react-proxy": "^1.1.7" + } + }, + "react-with-direction": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-with-direction/-/react-with-direction-1.3.0.tgz", + "integrity": "sha512-2TflEebNckTNUybw3Rzqjg4BwM/H380ZL5lsbZ5f4UTY2JyE5uQdQZK5T2w+BDJSAMcqoA2RDJYa4e7Cl6C2Kg==", + "requires": { + "airbnb-prop-types": "^2.8.1", + "brcast": "^2.0.2", + "deepmerge": "^1.5.1", + "direction": "^1.0.1", + "hoist-non-react-statics": "^2.3.1", + "object.assign": "^4.1.0", + "object.values": "^1.0.4", + "prop-types": "^15.6.0" + } + }, + "react-with-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/react-with-styles/-/react-with-styles-3.2.1.tgz", + "integrity": "sha512-L+x/EDgrKkqV6pTfDtLMShf7Xs+bVQ+HAT5rByX88QYX+ft9t5Gn4PWMmg36Ur21IVEBMGjjQQIJGJpBrzbsyg==", + "requires": { + "deepmerge": "^1.5.2", + "hoist-non-react-statics": "^2.5.0", + "prop-types": "^15.6.1", + "react-with-direction": "^1.3.0" + } + }, + "react-with-styles-interface-css": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-with-styles-interface-css/-/react-with-styles-interface-css-4.0.3.tgz", + "integrity": "sha512-wE43PIyjal2dexxyyx4Lhbcb+E42amoYPnkunRZkb9WTA+Z+9LagbyxwsI352NqMdFmghR0opg29dzDO4/YXbw==", + "requires": { + "array.prototype.flat": "^1.2.1", + "global-cache": "^1.2.1" + } + }, + "read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "dev": true, + "requires": { + "mute-stream": "~0.0.4" + } + }, + "read-cmd-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-1.0.1.tgz", + "integrity": "sha1-LV0Vd4ajfAVdIgd8MsU/gynpHHs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "read-package-json": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-2.0.13.tgz", + "integrity": "sha512-/1dZ7TRZvGrYqE0UAfN6qQb5GYBsNcqS1C0tNK601CFOJmtHI7NIGXwetEPU/OtoFHZL3hDxm4rolFFVE9Bnmg==", + "dev": true, + "requires": { + "glob": "^7.1.1", + "graceful-fs": "^4.1.2", + "json-parse-better-errors": "^1.0.1", + "normalize-package-data": "^2.0.0", + "slash": "^1.0.0" + } + }, + "read-package-tree": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/read-package-tree/-/read-package-tree-5.3.1.tgz", + "integrity": "sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw==", + "dev": true, + "requires": { + "read-package-json": "^2.0.0", + "readdir-scoped-modules": "^1.0.0", + "util-promisify": "^2.1.0" + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + }, + "dependencies": { + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + } + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdir-scoped-modules": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz", + "integrity": "sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw==", + "dev": true, + "requires": { + "debuglog": "^1.0.1", + "dezalgo": "^1.0.0", + "graceful-fs": "^4.1.2", + "once": "^1.3.0" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "realpath-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", + "integrity": "sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA==", + "dev": true, + "requires": { + "util.promisify": "^1.0.0" + } + }, + "redent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", + "integrity": "sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo=", + "dev": true, + "requires": { + "indent-string": "^3.0.0", + "strip-indent": "^2.0.0" + } + }, + "redux": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.0.tgz", + "integrity": "sha512-NnnHF0h0WVE/hXyrB6OlX67LYRuaf/rJcbWvnHHEPCF/Xa/AZpwhs/20WyqzQae5x4SD2F9nPObgBh2rxAgLiA==", + "requires": { + "loose-envify": "^1.1.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-multi": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/redux-multi/-/redux-multi-0.1.12.tgz", + "integrity": "sha1-KOH+XklnLLxb2KB/Cyrq8O+DVcI=" + }, + "redux-optimist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redux-optimist/-/redux-optimist-1.0.0.tgz", + "integrity": "sha512-AG1v8o6UZcGXTEH2jVcWG6KD+gEix+Cj9JXAAzln9MPkauSVd98H7N7EOOyT/v4c9N1mJB4sm1zfspGlLDkUEw==" + }, + "reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=" + }, + "refx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/refx/-/refx-3.0.0.tgz", + "integrity": "sha512-qmd73YvYiVWfKPECtE90ujmPwwtAnmtEOkBKgfNEuqJ4trTeKbqFV2UY878yFvHBvU7BBu4/w/Q8pk/t0zDpYA==" + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.0.2.tgz", + "integrity": "sha512-SbA/iNrBUf6Pv2zU8Ekv1Qbhv92yxL4hiDa2siuxs4KKn4oOoMDHXjAf7+Nz9qinUQ46B1LcWEi/PhJfPWpZWQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz", + "integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==" + }, + "regenerator-transform": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.4.tgz", + "integrity": "sha512-T0QMBjK3J0MtxjPmdIMXm72Wvj2Abb0Bd4HADdfijwMdoIsyQZ6fWC7kDFhk2YinBBEMZDL7Y7wh0J1sGx3S4A==", + "dev": true, + "requires": { + "private": "^0.1.6" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexp-tree": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.1.tgz", + "integrity": "sha512-HwRjOquc9QOwKTgbxvZTcddS5mlNlwePMQ3NFL8broajMLD5CXDAqas8Y5yxJH5QtZp5iRor3YCILd5pz71Cgw==", + "dev": true, + "requires": { + "cli-table3": "^0.5.0", + "colors": "^1.1.2", + "yargs": "^12.0.5" + }, + "dependencies": { + "camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true + }, + "colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.3.tgz", + "integrity": "sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.1.0.tgz", + "integrity": "sha512-I5u6Q1x7wxO0kdOpYBB28xueHADYps5uty/zg936CiG8NTe5sJL8EjrCuLneuDW3PlMdZBGDIn8BirEVdovZvg==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^1.0.0", + "p-is-promise": "^2.0.0" + } + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-is-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.0.0.tgz", + "integrity": "sha512-pzQPhYMCAgLAKPWD2jC3Se9fEfrD9npNos0y150EeqZll7akhEgGhTW/slB6lHku8AvYGiJ+YJ5hfHKePPgFWg==", + "dev": true + }, + "p-limit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.1.0.tgz", + "integrity": "sha512-NhURkNcrVB+8hNfLuysU8enY5xn2KXphsHBaC2YmRNTZRc7RWusw6apSpdEj3jo4CMb6W9nrF6tTnsJsJeyu6g==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, + "regexpu-core": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.5.4.tgz", + "integrity": "sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.0.2", + "regjsgen": "^0.5.0", + "regjsparser": "^0.6.0", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.1.0" + } + }, + "regextras": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/regextras/-/regextras-0.6.1.tgz", + "integrity": "sha512-EzIHww9xV2Kpqx+corS/I7OBmf2rZ0pKKJPsw5Dc+l6Zq1TslDmtRIP9maVn3UH+72MIXmn8zzDgP07ihQogUA==", + "dev": true + }, + "regjsgen": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.0.tgz", + "integrity": "sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA==", + "dev": true + }, + "regjsparser": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.0.tgz", + "integrity": "sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "remark": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-10.0.1.tgz", + "integrity": "sha512-E6lMuoLIy2TyiokHprMjcWNJ5UxfGQjaMSMhV+f4idM625UjjK4j798+gPs5mfjzDE6vL0oFKVeZM6gZVSVrzQ==", + "dev": true, + "requires": { + "remark-parse": "^6.0.0", + "remark-stringify": "^6.0.0", + "unified": "^7.0.0" + } + }, + "remark-parse": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-6.0.3.tgz", + "integrity": "sha512-QbDXWN4HfKTUC0hHa4teU463KclLAnwpn/FBn87j9cKYJWWawbiLgMfP2Q4XwhxxuuuOxHlw+pSN0OKuJwyVvg==", + "dev": true, + "requires": { + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^1.1.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^1.0.0", + "vfile-location": "^2.0.0", + "xtend": "^4.0.1" + } + }, + "remark-stringify": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-6.0.4.tgz", + "integrity": "sha512-eRWGdEPMVudijE/psbIDNcnJLRVx3xhfuEsTDGgH4GsFF91dVhw5nhmnBppafJ7+NWINW6C7ZwWbi30ImJzqWg==", + "dev": true, + "requires": { + "ccount": "^1.0.0", + "is-alphanumeric": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "longest-streak": "^2.0.1", + "markdown-escapes": "^1.0.0", + "markdown-table": "^1.1.0", + "mdast-util-compact": "^1.0.0", + "parse-entities": "^1.0.2", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "stringify-entities": "^1.0.1", + "unherit": "^1.0.4", + "xtend": "^4.0.1" + } + }, + "rememo": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rememo/-/rememo-3.0.0.tgz", + "integrity": "sha512-eWtut/7pqMRnSccbexb647iPjN7ir6Tmf4RG92ZVlykFEkHqGYy9tWnpHH3I+FS+WQ6lQ1i1iDgarYzGKgTcRQ==" + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + } + } + }, + "request-promise-core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.1.tgz", + "integrity": "sha1-Pu4AssWqgyOc+wTFcA2jb4HNCLY=", + "dev": true, + "requires": { + "lodash": "^4.13.1" + } + }, + "request-promise-native": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.5.tgz", + "integrity": "sha1-UoF3D2jgyXGeUWP9P6tIIhX0/aU=", + "dev": true, + "requires": { + "request-promise-core": "1.1.1", + "stealthy-require": "^1.1.0", + "tough-cookie": ">=2.3.3" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" + }, + "requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true + }, + "resolve": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", + "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", + "dev": true, + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-bin": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/resolve-bin/-/resolve-bin-0.4.0.tgz", + "integrity": "sha1-RxMiSYkRAa+xmZH+k3ywpfBy5dk=", + "dev": true, + "requires": { + "find-parent-dir": "~0.3.0" + } + }, + "resolve-cwd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", + "integrity": "sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "retry": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", + "integrity": "sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q=", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rgb": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rgb/-/rgb-0.1.0.tgz", + "integrity": "sha1-vieykej+/+rBvZlylyG/pA/AN7U=", + "dev": true + }, + "rgb-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz", + "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=", + "dev": true + }, + "rgba-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", + "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=", + "dev": true + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=", + "dev": true, + "requires": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "rsvp": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.4.tgz", + "integrity": "sha512-6FomvYPfs+Jy9TfXmBpBuMWNH94SgCsZmJKcanySzgNNP6LjWxBvyLTa9KaMfDDM5oxRfrKDB0r/qeRsLwnBfA==", + "dev": true + }, + "rtlcss": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-2.4.0.tgz", + "integrity": "sha512-hdjFhZ5FCI0ABOfyXOMOhBtwPWtANLCG7rOiOcRf+yi5eDdxmDjqBruWouEnwVdzfh/TWF6NNncIEsigOCFZOA==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "findup": "^0.1.5", + "mkdirp": "^0.5.1", + "postcss": "^6.0.14", + "strip-json-comments": "^2.0.0" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "run-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz", + "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==", + "dev": true + }, + "run-parallel": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", + "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==", + "dev": true + }, + "run-queue": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz", + "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=", + "dev": true, + "requires": { + "aproba": "^1.1.1" + } + }, + "rungen": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/rungen/-/rungen-0.3.2.tgz", + "integrity": "sha1-QAwJ6+kU57F+C27zJjQA/Cq8fLM=" + }, + "rx": { + "version": "2.3.24", + "resolved": "https://registry.npmjs.org/rx/-/rx-2.3.24.tgz", + "integrity": "sha1-FPlQpCF9fjXapxu8vljv9o6ksrc=", + "dev": true + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "dev": true + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "dev": true, + "requires": { + "rx-lite": "*" + } + }, + "rxjs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", + "integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-json-parse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz", + "integrity": "sha1-PnZyPjjf3aE8mx0poeB//uSzC1c=", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "safer-eval": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/safer-eval/-/safer-eval-1.3.5.tgz", + "integrity": "sha512-BJ//K2Y+EgCbOHEsDGS5YahYBcYy7JcFpKDo2ba5t4MnOGHYtk7HvQkcxTDFvjQvJ0CRcdas/PyF+gTTCay+3w==", + "dev": true, + "requires": { + "clones": "^1.2.0" + } + }, + "sane": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", + "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", + "dev": true, + "requires": { + "@cnakazawa/watch": "^1.0.3", + "anymatch": "^2.0.0", + "capture-exit": "^2.0.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", + "fb-watchman": "^2.0.0", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, + "sass-graph": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz", + "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^13.3.2" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "sass-loader": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-6.0.7.tgz", + "integrity": "sha512-JoiyD00Yo1o61OJsoP2s2kb19L1/Y2p3QFcCdWdF6oomBGKVYuZyqHWemRBfQ2uGYsk+CH3eCguXNfpjzlcpaA==", + "dev": true, + "requires": { + "clone-deep": "^2.0.1", + "loader-utils": "^1.0.1", + "lodash.tail": "^4.1.1", + "neo-async": "^2.5.0", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "scheduler": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", + "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + }, + "dependencies": { + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } + } + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha512-dYE8LhncfBUar6POCxMTm0Ln+erjeczqEvCJib5/7XNkdw1FkUGgwMPY360FY0FgPWQxHWCx29Jl3oejyGLM9Q==", + "dev": true, + "requires": { + "js-base64": "^2.1.8", + "source-map": "^0.4.2" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=" + }, + "semver": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", + "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "dev": true + }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", + "dev": true + } + } + }, + "serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha1-ULZ51WNc34Rme9yOWa9OW4HV9go=", + "dev": true + }, + "serialize-javascript": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.5.0.tgz", + "integrity": "sha512-Ga8c8NjAAp46Br4+0oZ2WxJCwIzwP60Gq1YPgU+39PiTVxyed/iKE/zyZI6+UlVYH5Q4PaQdHhcegIFPZTUfoQ==", + "dev": true + }, + "serialize-to-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-1.2.2.tgz", + "integrity": "sha512-mUc8vA5iJghe+O+3s0YDGFLMJcqitVFk787YKiv8a4sf6RX5W0u81b+gcHrp15O0fFa010dRBVZvwcKXOWsL9Q==", + "dev": true, + "requires": { + "js-beautify": "^1.8.9", + "safer-eval": "^1.3.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shallow-clone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-1.0.0.tgz", + "integrity": "sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==", + "dev": true, + "requires": { + "is-extendable": "^0.1.1", + "kind-of": "^5.0.0", + "mixin-object": "^2.0.1" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=", + "dev": true + }, + "shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.0.0.tgz", + "integrity": "sha1-UI0YOLPeWQq4dXsBGyXkMJAJRfc=", + "dev": true + }, + "shallow-equals": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shallow-equals/-/shallow-equals-1.0.0.tgz", + "integrity": "sha1-JLdL8cY0wR7Uxxgqbfb7MA3OQ5A=", + "dev": true + }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "dev": true, + "requires": { + "array-filter": "~0.0.0", + "array-map": "~0.0.0", + "array-reduce": "~0.0.0", + "jsonify": "~0.0.0" + }, + "dependencies": { + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", + "dev": true + } + } + }, + "shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true + }, + "showdown": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-1.8.6.tgz", + "integrity": "sha1-kepO47elRIqspoIKTifmkMatdxw=", + "requires": { + "yargs": "^10.0.3" + } + }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "simple-git": { + "version": "1.113.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-1.113.0.tgz", + "integrity": "sha512-i9WVsrK2u0G/cASI9nh7voxOk9mhanWY9eGtWBDSYql6m49Yk5/Fan6uZsDr/xmzv8n+eQ8ahKCoEr8cvU3h+g==", + "dev": true, + "requires": { + "debug": "^4.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "simple-html-tokenizer": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/simple-html-tokenizer/-/simple-html-tokenizer-0.5.7.tgz", + "integrity": "sha512-APW9iYbkJ5cijjX4Ljhf3VG8SwYPUJT5gZrwci/wieMabQxWFiV5VwsrP5c6GMRvXKEQMGkAB1d9dvW66dTqpg==" + }, + "simple-plist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.0.0.tgz", + "integrity": "sha512-043L2rO80LVF7zfZ+fqhsEkoJFvW8o59rt/l4ctx1TJWoTx7/jkiS1R5TatD15Z1oYnuLJytzE7gcnnBuIPL2g==", + "dev": true, + "requires": { + "bplist-creator": "0.0.7", + "bplist-parser": "0.1.1", + "plist": "^3.0.1" + } + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, + "sisteransi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.0.tgz", + "integrity": "sha512-N+z4pHB4AmUv0SjveWRd6q1Nj5w62m5jodv+GD8lvmbY/83T/rpbJGZOnK5T149OldDj4Db07BSv9xY4K6NTPQ==", + "dev": true + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + }, + "slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=", + "dev": true + }, + "smart-buffer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.0.2.tgz", + "integrity": "sha512-JDhEpTKzXusOqXZ0BUIdH+CjFdO/CR3tLlf5CN34IypI+xMmXW1uB16OOY8z3cICbJlDAVJzNbwBhNO0wt9OAw==", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "socks": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.2.tgz", + "integrity": "sha512-pCpjxQgOByDHLlNqlnh/mNSAxIUkyBBuwwhTcV+enZGbDaClPvHdvm6uvOwZfFJkam7cGhBNbb4JxiP8UZkRvQ==", + "dev": true, + "requires": { + "ip": "^1.1.5", + "smart-buffer": "4.0.2" + } + }, + "socks-proxy-agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz", + "integrity": "sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==", + "dev": true, + "requires": { + "agent-base": "~4.2.1", + "socks": "~2.3.2" + } + }, + "sort-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", + "integrity": "sha1-ZYU1WEhh7JfXMNbPQYIuH1ZoQSg=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + }, + "source-list-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", + "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-loader": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.2.4.tgz", + "integrity": "sha512-OU6UJUty+i2JDpTItnizPrlpOIBLmQbWMuBg9q5bVtnHACqw1tn9nNwqJLbv0/00JjnJb/Ee5g5WS5vrRv7zIQ==", + "dev": true, + "requires": { + "async": "^2.5.0", + "loader-utils": "^1.1.0" + } + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "spawn-command": { + "version": "0.0.2-1", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", + "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", + "dev": true + }, + "spawnd": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spawnd/-/spawnd-4.0.0.tgz", + "integrity": "sha512-ql3qhJnhAkvXpaqKBWOqou1rUTSQhFRaZkyOT+MTFB4xY3X+brgw6LTWV2wHuE9A6YPhrNe1cbg7S+jAYnbC0Q==", + "dev": true, + "requires": { + "exit": "^0.1.2", + "signal-exit": "^3.0.2", + "tree-kill": "^1.2.1", + "wait-port": "^0.2.2" + }, + "dependencies": { + "tree-kill": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.1.tgz", + "integrity": "sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q==", + "dev": true + } + } + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-2+EPwgbnmOIl8HjGBXXMd9NAu02vLjOO1nWw4kmeRDFyHn+M/ETfHxQUK0oXg8ctgVnl9t3rosNVsZ1jG61nDA==", + "dev": true + }, + "specificity": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz", + "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", + "dev": true + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "requires": { + "through": "2" + } + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "split2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", + "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", + "dev": true, + "requires": { + "through2": "^2.0.2" + } + }, + "sprintf-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz", + "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "ssri": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-5.3.0.tgz", + "integrity": "sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.1" + } + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=", + "dev": true + }, + "stacktrace-parser": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.6.tgz", + "integrity": "sha512-wXhu0Z8YgCGigUtHQq+J7pjXCppk3Um5DwH4qskOKHMlJmKwuuUSm+wDAgU7t4sbVjvuDTNGwOfFKgjMEqSflA==", + "dev": true, + "requires": { + "type-fest": "^0.3.0" + } + }, + "state-toggle": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.1.tgz", + "integrity": "sha512-Qe8QntFrrpWTnHwvwj2FZTgv+PKIsp0B9VxLzLLbSpPXWOgRgc5LVj/aTiSfK1RqIeF9jeC1UeOH8Q8y60A7og==", + "dev": true + }, + "static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "dev": true, + "requires": { + "escodegen": "^1.8.1" + } + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "static-module": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/static-module/-/static-module-2.2.5.tgz", + "integrity": "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==", + "dev": true, + "requires": { + "concat-stream": "~1.6.0", + "convert-source-map": "^1.5.1", + "duplexer2": "~0.1.4", + "escodegen": "~1.9.0", + "falafel": "^2.1.0", + "has": "^1.0.1", + "magic-string": "^0.22.4", + "merge-source-map": "1.0.4", + "object-inspect": "~1.4.0", + "quote-stream": "~1.0.2", + "readable-stream": "~2.3.3", + "shallow-copy": "~0.0.1", + "static-eval": "^2.0.0", + "through2": "~2.0.3" + }, + "dependencies": { + "escodegen": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.1.tgz", + "integrity": "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==", + "dev": true, + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "object-inspect": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz", + "integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==", + "dev": true + }, + "stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ=", + "dev": true + }, + "stream-each": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.2.tgz", + "integrity": "sha512-mc1dbFhGBxvTM3bIWmAAINbqiuAk9TATcfIQC8P+/+HJefgaiTlMn2dHvkX8qlI12KeYKSQ1Ua9RrIqrn1VPoA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "stream-shift": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", + "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "dev": true + }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true + }, + "string-length": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", + "integrity": "sha1-1A27aGo6zpYMHP/KVivyxF+DY+0=", + "dev": true, + "requires": { + "astral-regex": "^1.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string-template": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", + "integrity": "sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string.prototype.trim": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz", + "integrity": "sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.0", + "function-bind": "^1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "stringify-entities": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-1.3.2.tgz", + "integrity": "sha512-nrBAQClJAPN2p+uGCVJRPIPakKeKWZ9GtBCmormE7pWOSlHat7+x5A8gx85M7HM5Dt0BP3pP5RhVW77WdbJJ3A==", + "dev": true, + "requires": { + "character-entities-html4": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "requires": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "strong-log-transformer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", + "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", + "dev": true, + "requires": { + "duplexer": "^0.1.1", + "minimist": "^1.2.0", + "through": "^2.3.4" + } + }, + "style-search": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI=", + "dev": true + }, + "stylehacks": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz", + "integrity": "sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "stylelint": { + "version": "9.10.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-9.10.1.tgz", + "integrity": "sha512-9UiHxZhOAHEgeQ7oLGwrwoDR8vclBKlSX7r4fH0iuu0SfPwFaLkb1c7Q2j1cqg9P7IDXeAV2TvQML/fRQzGBBQ==", + "dev": true, + "requires": { + "autoprefixer": "^9.0.0", + "balanced-match": "^1.0.0", + "chalk": "^2.4.1", + "cosmiconfig": "^5.0.0", + "debug": "^4.0.0", + "execall": "^1.0.0", + "file-entry-cache": "^4.0.0", + "get-stdin": "^6.0.0", + "global-modules": "^2.0.0", + "globby": "^9.0.0", + "globjoin": "^0.1.4", + "html-tags": "^2.0.0", + "ignore": "^5.0.4", + "import-lazy": "^3.1.0", + "imurmurhash": "^0.1.4", + "known-css-properties": "^0.11.0", + "leven": "^2.1.0", + "lodash": "^4.17.4", + "log-symbols": "^2.0.0", + "mathml-tag-names": "^2.0.1", + "meow": "^5.0.0", + "micromatch": "^3.1.10", + "normalize-selector": "^0.2.0", + "pify": "^4.0.0", + "postcss": "^7.0.13", + "postcss-html": "^0.36.0", + "postcss-jsx": "^0.36.0", + "postcss-less": "^3.1.0", + "postcss-markdown": "^0.36.0", + "postcss-media-query-parser": "^0.2.3", + "postcss-reporter": "^6.0.0", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-safe-parser": "^4.0.0", + "postcss-sass": "^0.3.5", + "postcss-scss": "^2.0.0", + "postcss-selector-parser": "^3.1.0", + "postcss-syntax": "^0.36.2", + "postcss-value-parser": "^3.3.0", + "resolve-from": "^4.0.0", + "signal-exit": "^3.0.2", + "slash": "^2.0.0", + "specificity": "^0.4.1", + "string-width": "^3.0.0", + "style-search": "^0.1.0", + "sugarss": "^2.0.0", + "svg-tags": "^1.0.0", + "table": "^5.0.0" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", + "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", + "dev": true + }, + "ansi-regex": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.0.0.tgz", + "integrity": "sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w==", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "dev": true, + "requires": { + "path-type": "^3.0.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "fast-glob": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.6.tgz", + "integrity": "sha512-0BvMaZc1k9F+MeWWMe8pL6YltFzZYcJsYU7D4JyDA6PAczaXvxqQQ/z+mDF7/4Mw01DeUc+i3CTKajnkANkV4w==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.1.2", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.3", + "micromatch": "^3.1.10" + } + }, + "file-entry-cache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-4.0.0.tgz", + "integrity": "sha512-AVSwsnbV8vH/UVbvgEhf3saVQXORNv0ZzSkvkhQIaia5Tia+JhGTaa/ePUSVoPHQyGayQNmYfkzFi3WZV5zcpA==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + } + }, + "get-stdin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", + "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", + "dev": true + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "requires": { + "global-prefix": "^3.0.0" + } + }, + "global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "requires": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + } + }, + "globby": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-9.0.0.tgz", + "integrity": "sha512-q0qiO/p1w/yJ0hk8V9x1UXlgsXUxlGd0AHUOXZVXBO6aznDtpx7M8D1kBrCAItoPm+4l8r6ATXV1JpjY2SBQOw==", + "dev": true, + "requires": { + "array-union": "^1.0.2", + "dir-glob": "^2.2.1", + "fast-glob": "^2.2.6", + "glob": "^7.1.3", + "ignore": "^4.0.3", + "pify": "^4.0.1", + "slash": "^2.0.0" + }, + "dependencies": { + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + } + } + }, + "ignore": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.0.4.tgz", + "integrity": "sha512-WLsTMEhsQuXpCiG173+f3aymI43SXa+fB1rSfbzyP4GkPP+ZFVuO0/3sFUGNBtifisPeDcl/uD/Y2NxZ7xFq4g==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "known-css-properties": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.11.0.tgz", + "integrity": "sha512-bEZlJzXo5V/ApNNa5z375mJC6Nrz4vG43UgcSCrg2OHC+yuB6j0iDSrY7RQ/+PRofFB03wNIIt9iXIVLr4wc7w==", + "dev": true + }, + "merge2": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.3.tgz", + "integrity": "sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "string-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.0.0.tgz", + "integrity": "sha512-rr8CUxBbvOZDUvc5lNIJ+OC1nPVpz+Siw9VBtUjB9b6jZehZLFt0JMCZzShFHIsI8cbhm0EsNIfWJMFV3cu3Ew==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "strip-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.0.0.tgz", + "integrity": "sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow==", + "dev": true, + "requires": { + "ansi-regex": "^4.0.0" + } + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + } + } + }, + "stylelint-config-recommended": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-2.1.0.tgz", + "integrity": "sha512-ajMbivOD7JxdsnlS5945KYhvt7L/HwN6YeYF2BH6kE4UCLJR0YvXMf+2j7nQpJyYLZx9uZzU5G1ZOSBiWAc6yA==", + "dev": true + }, + "stylelint-config-recommended-scss": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-3.2.0.tgz", + "integrity": "sha512-M8BFHMRf8KNz5EQPKJd8nMCGmBd2o5coDEObfHVbEkyLDgjIf1V+U5dHjaGgvhm0zToUxshxN+Gc5wpbOOew4g==", + "dev": true, + "requires": { + "stylelint-config-recommended": "^2.0.0" + } + }, + "stylelint-config-wordpress": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-wordpress/-/stylelint-config-wordpress-13.1.0.tgz", + "integrity": "sha512-dpKj2/d3/XjDVoOvQzd54GoM8Rj5zldluOZKkVhBCc4JYMc6r1VYL5hpcgIjqy/i2Hyqg4Rh7zTafE/2AWq//w==", + "dev": true, + "requires": { + "stylelint-config-recommended": "^2.1.0", + "stylelint-config-recommended-scss": "^3.2.0", + "stylelint-scss": "^3.3.0" + } + }, + "stylelint-scss": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-3.3.0.tgz", + "integrity": "sha512-1E54Tsx/RPJDbGqLK8LcCUa62dB+KRg4zhqo/ub38PBpf16qz5mY/6VJCkQHSU3MygyyJDiFf/J5TLLznrRmGA==", + "dev": true, + "requires": { + "lodash": "^4.17.10", + "postcss-media-query-parser": "^0.2.3", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-selector-parser": "^4.0.0", + "postcss-value-parser": "^3.3.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-4.0.0.tgz", + "integrity": "sha512-5h+MvEjnzu1qy6MabjuoPatsGAjjDV9B24e7Cktjl+ClNtjVjmvAXjOFQr1u7RlWULKNGYaYVE4s+DIIQ4bOGA==", + "dev": true, + "requires": { + "cssesc": "^1.0.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "sugarss": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-2.0.0.tgz", + "integrity": "sha512-WfxjozUk0UVA4jm+U1d736AUpzSrNsQcIbyOkoE364GrtWmIrFdk5lksEupgWMD4VaT/0kVx1dobpiDumSgmJQ==", + "dev": true, + "requires": { + "postcss": "^7.0.2" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=", + "dev": true + }, + "svgo": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.2.1.tgz", + "integrity": "sha512-Y1+LyT4/y1ms4/0yxPMSlvx6dIbgklE9w8CIOnfeoFGB74MEkq8inSfEr6NhocTaFbyYp0a1dvNgRKGRmEBlzA==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.28", + "css-url-regex": "^1.1.0", + "csso": "^3.5.1", + "js-yaml": "^3.13.0", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "dependencies": { + "css-select": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.0.2.tgz", + "integrity": "sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^2.1.2", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "css-what": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", + "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "object.values": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz", + "integrity": "sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + } + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, + "symbol-tree": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz", + "integrity": "sha1-rifbOPZgp64uHDt9G8KQgZuFGeY=", + "dev": true + }, + "table": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/table/-/table-5.2.1.tgz", + "integrity": "sha512-qmhNs2GEHNqY5fd2Mo+8N1r2sw/rvTAAvBZTaTx+Y7PHLypqyrxr1MdIu0pLw6Xvl/Gi4ONu/sdceP8vvUjkyA==", + "dev": true, + "requires": { + "ajv": "^6.6.1", + "lodash": "^4.17.11", + "slice-ansi": "2.0.0", + "string-width": "^2.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "slice-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.0.0.tgz", + "integrity": "sha512-4j2WTWjp3GsZ+AOagyzVbzp4vWGtZ0hEZ/gDY/uTvm6MTxUfTUIsnMIFb1bn8o0RuXiqUw15H1bue8f22Vw2oQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + } + } + }, + "tannin": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tannin/-/tannin-1.1.0.tgz", + "integrity": "sha512-LxhcXqpMHEOVeVKmuG5aCPPsTXFlO373vrWkqN7FSJBVLS6lFOAg8ZGzIyGhrOf7Ho3xB4jdGedY1gi/8J1FCA==", + "requires": { + "@tannin/plural-forms": "^1.0.3" + } + }, + "tapable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.0.0.tgz", + "integrity": "sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg==", + "dev": true + }, + "tar": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.12", + "inherits": "2" + } + }, + "temp": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz", + "integrity": "sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=", + "dev": true, + "requires": { + "os-tmpdir": "^1.0.0", + "rimraf": "~2.2.6" + }, + "dependencies": { + "rimraf": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz", + "integrity": "sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=", + "dev": true + } + } + }, + "temp-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", + "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "dev": true + }, + "temp-write": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-write/-/temp-write-3.4.0.tgz", + "integrity": "sha1-jP9jD7fp2gXwR8dM5M5NaFRX1JI=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "is-stream": "^1.1.0", + "make-dir": "^1.0.0", + "pify": "^3.0.0", + "temp-dir": "^1.0.0", + "uuid": "^3.0.1" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "terser": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-3.17.0.tgz", + "integrity": "sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.10" + }, + "dependencies": { + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz", + "integrity": "sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^1.7.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + }, + "dependencies": { + "bluebird": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.0.tgz", + "integrity": "sha512-aBQ1FxIa7kSWCcmKHlcHFlT2jt6J/l4FzC7KcPELkOJOsPOb/bccdhmIrKDfXhwFrmc7vDoDrrepFvGqjyXGJg==", + "dev": true + }, + "cacache": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.3.tgz", + "integrity": "sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "chownr": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz", + "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==", + "dev": true + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", + "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "mississippi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "serialize-javascript": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.1.tgz", + "integrity": "sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "terser": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.3.4.tgz", + "integrity": "sha512-Kcrn3RiW8NtHBP0ssOAzwa2MsIRQ8lJWiBG/K7JgqPlomA3mtb2DEmp4/hrUA+Jujx+WZ02zqd7GYD+QRBB/2Q==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + } + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "test-exclude": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.2.tgz", + "integrity": "sha512-N2pvaLpT8guUpb5Fe1GJlmvmzH3x+DAKmmyEQmFP792QcLYoGE1syxztSvPD1V8yPe6VrcCt6YGQVjSRjCASsA==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "read-pkg-up": "^4.0.0", + "require-main-filename": "^2.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-4.0.0.tgz", + "integrity": "sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "read-pkg": "^3.0.0" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "text-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.0.0.tgz", + "integrity": "sha512-F91ZqLgvi1E0PdvmxMgp+gcf6q8fMH7mhdwWfzXnl1k+GbpQDmi8l7DzLC5JTASKbwpY3TfxajAUzAXcv2NmsQ==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "thenify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", + "integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "thread-loader": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/thread-loader/-/thread-loader-2.1.2.tgz", + "integrity": "sha512-7xpuc9Ifg6WU+QYw/8uUqNdRwMD+N5gjwHKMqETrs96Qn+7BHwECpt2Brzr4HFlf4IAkZsayNhmGdbkBsTJ//w==", + "dev": true, + "requires": { + "loader-runner": "^2.3.1", + "loader-utils": "^1.1.0", + "neo-async": "^2.6.0" + }, + "dependencies": { + "neo-async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.0.tgz", + "integrity": "sha512-MFh0d/Wa7vkKO3Y3LlacqAEeHK0mckVqzDieUKTT+KGxi+zIpeVsFxymkIiRpbpDziHc290Xr9A1O4Om7otoRA==", + "dev": true + } + } + }, + "throat": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-4.1.0.tgz", + "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", + "dev": true + }, + "timers-browserify": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.10.tgz", + "integrity": "sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tiny-emitter": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz", + "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow==" + }, + "tiny-inflate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.2.tgz", + "integrity": "sha1-k9nez/yIBb1X6uQxDwt0Xptvs6c=", + "dev": true + }, + "tiny-lr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz", + "integrity": "sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA==", + "dev": true, + "requires": { + "body": "^5.1.0", + "debug": "^3.1.0", + "faye-websocket": "~0.10.0", + "livereload-js": "^2.3.0", + "object-assign": "^4.1.0", + "qs": "^6.4.0" + } + }, + "tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "dev": true + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "touch": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/touch/-/touch-0.0.3.tgz", + "integrity": "sha1-Ua7z1ElXHU8oel2Hyci0kYGg2x0=", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "dev": true, + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, + "tree-kill": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.0.tgz", + "integrity": "sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg==", + "dev": true + }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=", + "dev": true + }, + "trim-newlines": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", + "integrity": "sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=", + "dev": true + }, + "trim-off-newlines": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz", + "integrity": "sha1-n5up2e+odkw4dpi8v+sshI8RrbM=", + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "trim-trailing-lines": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz", + "integrity": "sha512-bWLv9BbWbbd7mlqqs2oQYnLD/U/ZqeJeJwbO0FG2zA1aTq+HTvxfHNKFa/HGCVyJpDiioUYaBhfiT6rgk+l4mg==", + "dev": true + }, + "trough": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.3.tgz", + "integrity": "sha512-fwkLWH+DimvA4YCy+/nvJd61nWQQ2liO/nF/RjkTpiOGi+zxZzVkhb1mvbHIIW4b/8nDsYI8uTmAlc0nNkRMOw==", + "dev": true + }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "requires": { + "glob": "^7.1.2" + } + }, + "tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "dev": true + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "dev": true + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "turbo-combine-reducers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turbo-combine-reducers/-/turbo-combine-reducers-1.0.2.tgz", + "integrity": "sha512-gHbdMZlA6Ym6Ur5pSH/UWrNQMIM9IqTH6SoL1DbHpqEdQ8i+cFunSmSlFykPt0eGQwZ4d/XTHOl74H0/kFBVWw==" + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-fest": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", + "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", + "dev": true + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typescript": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz", + "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==", + "dev": true + }, + "ua-parser-js": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.18.tgz", + "integrity": "sha512-LtzwHlVHwFGTptfNSgezHp7WUlwiqb0gA9AALRbKaERfxwJoiX0A73QbTToxteIAuIaFshhgIZfqK8s7clqgnA==" + }, + "uglify-js": { + "version": "3.5.15", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.15.tgz", + "integrity": "sha512-fe7aYFotptIddkwcm6YuA0HmknBZ52ZzOsUxZEdhhkSsz7RfjHDX2QDxwKTiv4JQ5t5NhfmpgAK+J7LiDhKSqg==", + "dev": true, + "optional": true, + "requires": { + "commander": "~2.20.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "uid-number": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz", + "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=", + "dev": true + }, + "ultron": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", + "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=", + "dev": true + }, + "umask": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/umask/-/umask-1.1.0.tgz", + "integrity": "sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0=", + "dev": true + }, + "uncss": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/uncss/-/uncss-0.16.2.tgz", + "integrity": "sha1-OyJpxZAS2nxmy+mPvt3e75TwZJw=", + "dev": true, + "requires": { + "commander": "^2.9.0", + "glob": "^7.0.3", + "is-absolute-url": "^2.0.0", + "is-html": "^1.0.0", + "jsdom": "^11.3.0", + "lodash": "^4.13.1", + "postcss": "^6.0.14", + "postcss-selector-parser": "3.1.1", + "request": "^2.72.0" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "postcss-selector-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz", + "integrity": "sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU=", + "dev": true, + "requires": { + "dot-prop": "^4.1.1", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "underscore": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=", + "dev": true + }, + "unherit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.1.tgz", + "integrity": "sha512-+XZuV691Cn4zHsK0vkKYwBEwB74T3IZIcxrgn2E4rKwTfFyI1zCh7X7grwh9Re08fdPlarIdyWgI8aVB3F5A5g==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "xtend": "^4.0.1" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz", + "integrity": "sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.4.tgz", + "integrity": "sha512-2WSLa6OdYd2ng8oqiGIWnJqyFArvhn+5vgx5GTxMbUYjCYKUcuKS62YLFF0R/BDGlB1yzXjQOLtPAfHsgirEpg==", + "dev": true + }, + "unicode-trie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-0.3.1.tgz", + "integrity": "sha1-1nHd3YkQGgi6w3tqUWEBBgIFIIU=", + "dev": true, + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + }, + "dependencies": { + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=", + "dev": true + } + } + }, + "unified": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-7.1.0.tgz", + "integrity": "sha512-lbk82UOIGuCEsZhPj8rNAkXSDXd6p0QLzIuSsCdxrqnqU56St4eyOB+AlXsVgVeRmetPTYydIuvFfpDIed8mqw==", + "dev": true, + "requires": { + "@types/unist": "^2.0.0", + "@types/vfile": "^3.0.0", + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^1.1.0", + "trough": "^1.0.0", + "vfile": "^3.0.0", + "x-is-string": "^0.1.0" + } + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "uniqs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", + "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", + "dev": true + }, + "unique-filename": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.0.tgz", + "integrity": "sha1-0F8v5AMlYIcfMOk8vnNe6iAVFPM=", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.0.tgz", + "integrity": "sha1-22Z258fMBimHj/GWCXx4hVrp9Ks=", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "unist-util-find-all-after": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz", + "integrity": "sha512-nDl79mKpffXojLpCimVXnxhlH/jjaTnDuScznU9J4jjsaUtBdDbxmlc109XtcqxY4SDO0SwzngsxxW8DIISt1w==", + "dev": true, + "requires": { + "unist-util-is": "^2.0.0" + } + }, + "unist-util-is": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-2.1.2.tgz", + "integrity": "sha512-YkXBK/H9raAmG7KXck+UUpnKiNmUdB+aBGrknfQ4EreE1banuzrKABx3jP6Z5Z3fMSPMQQmeXBlKpCbMwBkxVw==", + "dev": true + }, + "unist-util-modify-children": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-1.1.2.tgz", + "integrity": "sha512-GRi04yhng1WqBf5RBzPkOtWAadcZS2gvuOgNn/cyJBYNxtTuyYqTKN0eg4rC1YJwGnzrqfRB3dSKm8cNCjNirg==", + "dev": true, + "requires": { + "array-iterate": "^1.0.0" + } + }, + "unist-util-remove-position": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz", + "integrity": "sha512-XxoNOBvq1WXRKXxgnSYbtCF76TJrRoe5++pD4cCBsssSiWSnPEktyFrFLE8LTk3JW5mt9hB0Sk5zn4x/JeWY7Q==", + "dev": true, + "requires": { + "unist-util-visit": "^1.1.0" + } + }, + "unist-util-stringify-position": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz", + "integrity": "sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ==", + "dev": true + }, + "unist-util-visit": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.0.tgz", + "integrity": "sha512-FiGu34ziNsZA3ZUteZxSFaczIjGmksfSgdKqBfOejrrfzyUy5b7YrlzT1Bcvi+djkYDituJDy2XB7tGTeBieKw==", + "dev": true, + "requires": { + "unist-util-visit-parents": "^2.0.0" + } + }, + "unist-util-visit-parents": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz", + "integrity": "sha512-6B0UTiMfdWql4cQ03gDTCSns+64Zkfo2OCbK31Ov0uMizEz+CJeAp0cgZVb5Fhmcd7Bct2iRNywejT0orpbqUA==", + "dev": true, + "requires": { + "unist-util-is": "^2.1.2" + } + }, + "universal-user-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-2.1.0.tgz", + "integrity": "sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==", + "dev": true, + "requires": { + "os-name": "^3.0.0" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", + "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=", + "dev": true + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util-promisify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/util-promisify/-/util-promisify-2.1.0.tgz", + "integrity": "sha1-PCI2R2xNMsX/PEcAKt18E7moKlM=", + "dev": true, + "requires": { + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "util.promisify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", + "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "v8-compile-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.0.2.tgz", + "integrity": "sha512-1wFuMUIM16MDJRCrpbpuEPTUGmM5QMUg0cr3KFwra2XgOgFcPGDQHDh3CszSCD2Zewc/dh/pamNEW8CbfDebUw==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz", + "integrity": "sha512-63ZOUnL4SIXj4L0NixR3L1lcjO38crAbgrTpl28t8jjrfuiOBL5Iygm+60qPs/KsZGzPNg6Smnc/oY16QTjF0g==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha1-X6kS2B630MdK/BQN5zF/DKffQ34=", + "dev": true, + "requires": { + "builtins": "^1.0.3" + } + }, + "validator": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz", + "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + }, + "vendors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.2.tgz", + "integrity": "sha512-w/hry/368nO21AN9QljsaIhb9ZiZtZARoVH5f3CsFbawdLdayCgKRPup7CggujvySMxx0I91NOyxdVENohprLQ==", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vfile": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-3.0.1.tgz", + "integrity": "sha512-y7Y3gH9BsUSdD4KzHsuMaCzRjglXN0W2EcMf0gpvu6+SbsGhMje7xDc8AEoeXy6mIwCKMI6BkjMsRjzQbhMEjQ==", + "dev": true, + "requires": { + "is-buffer": "^2.0.0", + "replace-ext": "1.0.0", + "unist-util-stringify-position": "^1.0.0", + "vfile-message": "^1.0.0" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==", + "dev": true + }, + "replace-ext": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", + "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=", + "dev": true + } + } + }, + "vfile-location": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-2.0.3.tgz", + "integrity": "sha512-zM5/l4lfw1CBoPx3Jimxoc5RNDAHHpk6AM6LM0pTIkm5SUSsx8ZekZ0PVdf0WEZ7kjlhSt7ZlqbRL6Cd6dBs6A==", + "dev": true + }, + "vfile-message": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-1.0.1.tgz", + "integrity": "sha512-vSGCkhNvJzO6VcWC6AlJW4NtYOVtS+RgCaqFIYUjoGIlHnFL+i0LbtYvonDWOMcB97uTPT4PRsyYY7REWC9vug==", + "dev": true, + "requires": { + "unist-util-stringify-position": "^1.1.1" + } + }, + "vlq": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", + "integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==", + "dev": true + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "dev": true, + "requires": { + "indexof": "0.0.1" + } + }, + "w3c-hr-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", + "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=", + "dev": true, + "requires": { + "browser-process-hrtime": "^0.1.2" + } + }, + "wait-on": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-3.3.0.tgz", + "integrity": "sha512-97dEuUapx4+Y12aknWZn7D25kkjMk16PbWoYzpSdA8bYpVfS6hpl2a2pOWZ3c+Tyt3/i4/pglyZctG3J4V1hWQ==", + "dev": true, + "requires": { + "@hapi/joi": "^15.0.3", + "core-js": "^2.6.5", + "minimist": "^1.2.0", + "request": "^2.88.0", + "rx": "^4.1.0" + }, + "dependencies": { + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "dev": true + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "rx": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz", + "integrity": "sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=", + "dev": true + } + } + }, + "wait-port": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.2.tgz", + "integrity": "sha1-1RpJHkhKF791qUfnEaLwErTm8uM=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "commander": "^2.9.0", + "debug": "^2.6.6" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "dev": true, + "requires": { + "makeerror": "1.0.x" + } + }, + "watchpack": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz", + "integrity": "sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0" + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "webpack": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.41.0.tgz", + "integrity": "sha512-yNV98U4r7wX1VJAj5kyMsu36T8RPPQntcb5fJLOsMz/pt/WrKC0Vp1bAlqPLkA1LegSwQwf6P+kAbyhRKVQ72g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-module-context": "1.8.5", + "@webassemblyjs/wasm-edit": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5", + "acorn": "^6.2.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.1.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.1", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.1", + "watchpack": "^1.6.0", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "acorn": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz", + "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==", + "dev": true + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.2.3.tgz", + "integrity": "sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^2.0.0", + "json5": "^1.0.1" + } + }, + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, + "node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" + } + }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + }, + "vm-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", + "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "dev": true + }, + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + } + } + }, + "webpack-bundle-analyzer": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.3.2.tgz", + "integrity": "sha512-7qvJLPKB4rRWZGjVp5U1KEjwutbDHSKboAl0IfafnrdXMrgC0tOtZbQD6Rw0u4cmpgRN4O02Fc0t8eAT+FgGzA==", + "dev": true, + "requires": { + "acorn": "^6.0.7", + "acorn-walk": "^6.1.1", + "bfj": "^6.1.1", + "chalk": "^2.4.1", + "commander": "^2.18.0", + "ejs": "^2.6.1", + "express": "^4.16.3", + "filesize": "^3.6.1", + "gzip-size": "^5.0.0", + "lodash": "^4.17.10", + "mkdirp": "^0.5.1", + "opener": "^1.5.1", + "ws": "^6.0.0" + }, + "dependencies": { + "acorn": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.1.1.tgz", + "integrity": "sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA==", + "dev": true + } + } + }, + "webpack-cli": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.1.2.tgz", + "integrity": "sha512-Cnqo7CeqeSvC6PTdts+dywNi5CRlIPbLx1AoUPK2T6vC1YAugMG3IOoO9DmEscd+Dghw7uRlnzV1KwOe5IrtgQ==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "enhanced-resolve": "^4.1.0", + "global-modules-path": "^2.3.0", + "import-local": "^2.0.0", + "interpret": "^1.1.0", + "loader-utils": "^1.1.0", + "supports-color": "^5.5.0", + "v8-compile-cache": "^2.0.2", + "yargs": "^12.0.2" + }, + "dependencies": { + "camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "execa": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", + "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "import-local": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", + "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", + "dev": true, + "requires": { + "pkg-dir": "^3.0.0", + "resolve-cwd": "^2.0.0" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.0.0.tgz", + "integrity": "sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^1.0.0", + "p-is-promise": "^1.1.0" + } + }, + "os-locale": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.0.1.tgz", + "integrity": "sha512-7g5e7dmXPtzcP4bgsZ8ixDVqA7oWYuEz4lOSujeWyliPai4gfVDiFIcwBg3aGCPnmSGfzOKTK3ccPn0CKv3DBw==", + "dev": true, + "requires": { + "execa": "^0.10.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-limit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.0.0.tgz", + "integrity": "sha512-fl5s52lI5ahKCernzzIyAP0QAZbGIovtVHGwpcu1Jr/EpzLVDI2myISHwGqK7m8uQFugVWSrbxH7XnhGtvEc+A==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "v8-compile-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.0.2.tgz", + "integrity": "sha512-1wFuMUIM16MDJRCrpbpuEPTUGmM5QMUg0cr3KFwra2XgOgFcPGDQHDh3CszSCD2Zewc/dh/pamNEW8CbfDebUw==", + "dev": true + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "webpack-livereload-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-livereload-plugin/-/webpack-livereload-plugin-2.2.0.tgz", + "integrity": "sha512-sx9xA5mHoNOUgLQI0PmXT3KV9ecsVmUaTgr+fsoL69qAOHw/7VzkL1+ZMDQ8n0dPbWounswK6cBRSgMod7Nhgg==", + "dev": true, + "requires": { + "portfinder": "^1.0.17", + "tiny-lr": "^1.1.1" + } + }, + "webpack-sources": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", + "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "websocket-driver": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", + "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=", + "dev": true, + "requires": { + "http-parser-js": ">=0.4.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", + "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.3.tgz", + "integrity": "sha512-jLBwwKUhi8WtBfsMQlL4bUUcT8sMkAtQinscJAe/M4KHCkHuUJAF6vuB0tueNIw4c8ziO6AkRmgY+jL3a0iiPw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.19" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", + "dev": true + } + } + }, + "whatwg-fetch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz", + "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==" + }, + "whatwg-mimetype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.1.0.tgz", + "integrity": "sha512-FKxhYLytBQiUKjkYteN71fAUA3g6KpNXoho1isLiLSB3N1G4F35Q5vUxWfKFhBwi5IWF27VE6WxhrnnC+m0Mew==", + "dev": true + }, + "whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "windows-release": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.2.0.tgz", + "integrity": "sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==", + "dev": true, + "requires": { + "execa": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "worker-farm": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", + "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==", + "dev": true, + "requires": { + "errno": "~0.1.7" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "write-file-atomic": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "write-json-file": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/write-json-file/-/write-json-file-3.2.0.tgz", + "integrity": "sha512-3xZqT7Byc2uORAatYiP3DHUUAVEkNOswEWNs9H5KXiicRTvzYzYqKjYc4G7p+8pltvAw641lVByKVtMpf+4sYQ==", + "dev": true, + "requires": { + "detect-indent": "^5.0.0", + "graceful-fs": "^4.1.15", + "make-dir": "^2.1.0", + "pify": "^4.0.1", + "sort-keys": "^2.0.0", + "write-file-atomic": "^2.4.2" + }, + "dependencies": { + "graceful-fs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.1.tgz", + "integrity": "sha512-b9usnbDGnD928gJB3LrCmxoibr3VE4U2SMo5PBuBnokWyDADTqDPXg4YpwKF1trpH+UbGp7QLicO3+aWEy0+mw==", + "dev": true + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } + } + }, + "write-pkg": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/write-pkg/-/write-pkg-3.2.0.tgz", + "integrity": "sha512-tX2ifZ0YqEFOF1wjRW2Pk93NLsj02+n1UP5RvO6rCs0K6R2g1padvf006cY74PQJKMGS2r42NK7FD0dG6Y6paw==", + "dev": true, + "requires": { + "sort-keys": "^2.0.0", + "write-json-file": "^2.2.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "write-json-file": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/write-json-file/-/write-json-file-2.3.0.tgz", + "integrity": "sha1-K2TIozAE1UuGmMdtWFp3zrYdoy8=", + "dev": true, + "requires": { + "detect-indent": "^5.0.0", + "graceful-fs": "^4.1.2", + "make-dir": "^1.0.0", + "pify": "^3.0.0", + "sort-keys": "^2.0.0", + "write-file-atomic": "^2.0.0" + } + } + } + }, + "ws": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.0.0.tgz", + "integrity": "sha512-c2UlYcAZp1VS8AORtpq6y4RJIkJ9dQz18W32SpR/qXGfLDZ2jU4y4wKvvZwqbi7U6gxFQTeE+urMbXU/tsDy4w==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + }, + "x-is-string": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", + "integrity": "sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI=", + "dev": true + }, + "xcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-2.0.0.tgz", + "integrity": "sha512-5xF6RCjAdDEiEsbbZaS/gBRt3jZ/177otZcpoLCjGN/u1LrfgH7/Sgeeavpr/jELpyDqN2im3AKosl2G2W8hfw==", + "dev": true, + "requires": { + "simple-plist": "^1.0.0", + "uuid": "^3.3.2" + } + }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "dev": true + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", + "dev": true + }, + "xmldoc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-0.4.0.tgz", + "integrity": "sha1-0lciS+g5PqrL+DfvIn/Y7CWzaIg=", + "dev": true, + "requires": { + "sax": "~1.1.1" + }, + "dependencies": { + "sax": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.6.tgz", + "integrity": "sha1-XWFr6KXmB9VOEUr65Vt+ry/MMkA=", + "dev": true + } + } + }, + "xmldom": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", + "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=", + "dev": true + }, + "xpipe": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/xpipe/-/xpipe-1.0.5.tgz", + "integrity": "sha1-jdi/Rfw/f1Xw4FS4ePQ6YmFNr98=", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, + "yargs": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-10.1.2.tgz", + "integrity": "sha512-ivSoxqBGYOqQVruxD35+EyCFDYNEFL/Uo6FcOnz+9xZdZzK0Zzw4r4KhbrME1Oo2gOggwJod2MnsdamSG7H9ig==", + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.1.1", + "find-up": "^2.1.0", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^8.1.0" + } + }, + "yargs-parser": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-8.1.0.tgz", + "integrity": "sha512-yP+6QqN8BmrgW2ggLtTbdrOyBNSI7zBa4IykmiV5R1wl1JWNxQvWhMfMdmzIYtKU7oP3OOInY/tl2ov3BDjnJQ==", + "requires": { + "camelcase": "^4.1.0" + } + }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "dev": true, + "requires": { + "fd-slicer": "~1.0.1" + } + } + } +} diff --git a/gb-src/package.json b/gb-src/package.json new file mode 100644 index 0000000000000..ae7bce3c4dc7e --- /dev/null +++ b/gb-src/package.json @@ -0,0 +1,261 @@ +{ + "name": "gutenberg", + "version": "6.6.0-rc.1", + "private": true, + "description": "A new WordPress editor experience.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "WordPress", + "editor" + ], + "homepage": "https://github.com/WordPress/gutenberg/", + "repository": "git+https://github.com/WordPress/gutenberg.git", + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "config": { + "GUTENBERG_PHASE": 2 + }, + "dependencies": { + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/annotations": "file:packages/annotations", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/autop": "file:packages/autop", + "@wordpress/blob": "file:packages/blob", + "@wordpress/block-directory": "file:packages/block-directory", + "@wordpress/block-editor": "file:packages/block-editor", + "@wordpress/block-library": "file:packages/block-library", + "@wordpress/block-serialization-default-parser": "file:packages/block-serialization-default-parser", + "@wordpress/block-serialization-spec-parser": "file:packages/block-serialization-spec-parser", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/core-data": "file:packages/core-data", + "@wordpress/data": "file:packages/data", + "@wordpress/data-controls": "file:packages/data-controls", + "@wordpress/date": "file:packages/date", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/dom": "file:packages/dom", + "@wordpress/dom-ready": "file:packages/dom-ready", + "@wordpress/edit-post": "file:packages/edit-post", + "@wordpress/edit-widgets": "file:packages/edit-widgets", + "@wordpress/editor": "file:packages/editor", + "@wordpress/element": "file:packages/element", + "@wordpress/escape-html": "file:packages/escape-html", + "@wordpress/format-library": "file:packages/format-library", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/html-entities": "file:packages/html-entities", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", + "@wordpress/media-utils": "file:packages/media-utils", + "@wordpress/notices": "file:packages/notices", + "@wordpress/nux": "file:packages/nux", + "@wordpress/plugins": "file:packages/plugins", + "@wordpress/priority-queue": "file:packages/priority-queue", + "@wordpress/redux-routine": "file:packages/redux-routine", + "@wordpress/rich-text": "file:packages/rich-text", + "@wordpress/server-side-render": "file:packages/server-side-render", + "@wordpress/shortcode": "file:packages/shortcode", + "@wordpress/token-list": "file:packages/token-list", + "@wordpress/url": "file:packages/url", + "@wordpress/viewport": "file:packages/viewport", + "@wordpress/wordcount": "file:packages/wordcount" + }, + "devDependencies": { + "@actions/core": "1.0.0", + "@actions/github": "1.0.0", + "@babel/core": "7.4.5", + "@babel/plugin-syntax-jsx": "7.2.0", + "@babel/runtime-corejs3": "7.4.5", + "@babel/traverse": "7.4.5", + "@octokit/rest": "16.26.0", + "@wordpress/babel-plugin-import-jsx-pragma": "file:packages/babel-plugin-import-jsx-pragma", + "@wordpress/babel-plugin-makepot": "file:packages/babel-plugin-makepot", + "@wordpress/babel-preset-default": "file:packages/babel-preset-default", + "@wordpress/browserslist-config": "file:packages/browserslist-config", + "@wordpress/custom-templated-path-webpack-plugin": "file:packages/custom-templated-path-webpack-plugin", + "@wordpress/dependency-extraction-webpack-plugin": "file:packages/dependency-extraction-webpack-plugin", + "@wordpress/docgen": "file:packages/docgen", + "@wordpress/e2e-test-utils": "file:packages/e2e-test-utils", + "@wordpress/e2e-tests": "file:packages/e2e-tests", + "@wordpress/eslint-plugin": "file:packages/eslint-plugin", + "@wordpress/jest-console": "file:packages/jest-console", + "@wordpress/jest-preset-default": "file:packages/jest-preset-default", + "@wordpress/jest-puppeteer-axe": "file:packages/jest-puppeteer-axe", + "@wordpress/library-export-default-webpack-plugin": "file:packages/library-export-default-webpack-plugin", + "@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config", + "@wordpress/postcss-themes": "file:packages/postcss-themes", + "@wordpress/scripts": "file:packages/scripts", + "babel-plugin-inline-json-import": "0.3.2", + "babel-plugin-react-native-classname-to-style": "1.2.2", + "babel-plugin-react-native-platform-specific-extensions": "1.1.1", + "benchmark": "2.1.4", + "browserslist": "4.7.0", + "chalk": "2.4.2", + "commander": "2.20.0", + "concurrently": "3.5.0", + "copy-webpack-plugin": "4.5.2", + "cross-env": "3.2.4", + "cssnano": "4.1.10", + "deep-freeze": "0.0.1", + "enzyme": "3.9.0", + "eslint-plugin-eslint-comments": "3.1.2", + "eslint-plugin-import": "2.18.2", + "fast-glob": "2.2.7", + "fbjs": "0.8.17", + "fs-extra": "8.0.1", + "glob": "7.1.2", + "husky": "3.0.5", + "inquirer": "6.3.1", + "is-equal-shallow": "0.1.3", + "jest-junit": "6.4.0", + "jest-serializer-enzyme": "1.0.0", + "jsdom": "11.12.0", + "lerna": "3.16.4", + "lint-staged": "9.2.5", + "lodash": "4.17.15", + "make-dir": "3.0.0", + "metro-react-native-babel-preset": "0.55.0", + "metro-react-native-babel-transformer": "0.55.0", + "mkdirp": "0.5.1", + "node-sass": "4.14.1", + "node-watch": "0.6.0", + "parcel-bundler": "1.12.3", + "postcss": "7.0.13", + "progress": "2.0.3", + "react": "16.9.0", + "react-dom": "16.9.0", + "react-native": "0.60.0", + "react-test-renderer": "16.9.0", + "rimraf": "2.6.2", + "rtlcss": "2.4.0", + "sass-loader": "6.0.7", + "semver": "6.0.0", + "shallow-equal": "1.0.0", + "shallow-equals": "1.0.0", + "shallowequal": "1.1.0", + "simple-git": "1.113.0", + "source-map-loader": "0.2.4", + "sprintf-js": "1.1.1", + "stylelint-config-wordpress": "13.1.0", + "typescript": "3.5.3", + "uuid": "3.3.2", + "webpack": "4.41.0", + "worker-farm": "1.7.0" + }, + "npmPackageJsonLintConfig": { + "extends": "@wordpress/npm-package-json-lint-config", + "rules": { + "description-format": [ + "error", + { + "requireCapitalFirstLetter": true, + "requireEndingPeriod": true + } + ], + "require-publishConfig": "error", + "require-repository-directory": "error", + "valid-values-author": [ + "error", + [ + "The WordPress Contributors" + ] + ], + "valid-values-publishConfig": [ + "error", + [ + { + "access": "public" + } + ] + ] + } + }, + "scripts": { + "prebuild": "npm run check-engines", + "clean:packages": "rimraf ./packages/*/build ./packages/*/build-module ./packages/*/build-style ./packages/*/node_modules", + "prebuild:packages": "npm run clean:packages && lerna run build", + "build:packages": "node ./bin/packages/build.js", + "build": "npm run build:packages && wp-scripts build", + "check-engines": "wp-scripts check-engines", + "check-licenses": "concurrently \"wp-scripts check-licenses --prod --gpl2\" \"wp-scripts check-licenses --dev\"", + "precheck-local-changes": "npm run docs:build", + "check-local-changes": "( git diff -U0 | xargs -0 node bin/process-git-diff ) || ( echo \"There are local uncommitted changes after one or both of 'npm install' or 'npm run docs:build'!\" && exit 1 );", + "predev": "npm run check-engines", + "dev": "npm run build:packages && concurrently \"wp-scripts start\" \"npm run dev:packages\"", + "dev:packages": "node ./bin/packages/watch.js", + "docs:build": "node ./docs/tool/index.js && node ./bin/update-readmes.js", + "fixtures:clean": "rimraf \"packages/e2e-tests/fixtures/blocks/*.+(json|serialized.html)\"", + "fixtures:server-registered": "wp-scripts env docker-run php ./bin/get-server-blocks.php > test/integration/full-content/server-registered.json", + "fixtures:generate": "npm run fixtures:server-registered && cross-env GENERATE_MISSING_FIXTURES=y npm run test-unit", + "fixtures:regenerate": "npm run fixtures:clean && npm run fixtures:generate", + "lint": "concurrently \"npm run lint-js\" \"npm run lint-pkg-json\" \"npm run lint-css\" \"npm run lint-types\"", + "lint-js": "wp-scripts lint-js", + "lint-js:fix": "npm run lint-js -- --fix", + "lint-php": "wp-scripts env lint-php", + "lint-pkg-json": "wp-scripts lint-pkg-json ./packages", + "lint-css": "wp-scripts lint-style '**/*.scss'", + "lint-css:fix": "npm run lint-css -- --fix", + "lint-types": "tsc", + "package-plugin": "./bin/build-plugin-zip.sh", + "pot-to-php": "./bin/pot-to-php.js", + "prepublishOnly": "npm run build:packages", + "publish:check": "lerna updated", + "publish:patch": "lerna publish patch --no-private --dist-tag wp-5.3", + "test": "npm run lint && npm run test-unit", + "pretest-e2e": "npm run env reinstall", + "test-e2e": "wp-scripts test-e2e --config packages/e2e-tests/jest.config.js", + "test-e2e:watch": "npm run test-e2e -- --watch", + "test-performance": "wp-scripts test-e2e --config packages/e2e-tests/jest.performance.config.js", + "test-php": "npm run lint-php && npm run test-unit-php", + "test-unit": "wp-scripts test-unit-js --config test/unit/jest.config.js", + "test-unit:update": "npm run test-unit -- --updateSnapshot", + "test-unit:watch": "npm run test-unit -- --watch", + "test-unit-php": "wp-scripts env test-php", + "test-unit-php-multisite": "cross-env WP_MULTISITE=1 wp-scripts env test-php", + "test-unit:native": "cd test/native/ && cross-env NODE_ENV=test jest --config ./jest.config.js", + "test-unit:native:debug": "cd test/native/ && node --inspect ../../node_modules/.bin/jest --runInBand --config ./jest.config.js", + "playground:build": "npm run build:packages && parcel build playground/src/index.html -d playground/dist", + "playground:start": "concurrently \"npm run dev:packages\" \"parcel playground/src/index.html -d playground/dist\"", + "preenv": "npm run check-engines", + "env": "wp-scripts env" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "packages/*/package.json": [ + "wp-scripts lint-pkg-json" + ], + "*.scss": [ + "wp-scripts lint-style" + ], + "*.js": [ + "wp-scripts lint-js" + ], + "{docs/{toc.json,tool/*.js},packages/{*/README.md,*/src/{actions,selectors}.js,components/src/*/**/README.md}}": [ + "node ./docs/tool/index.js" + ], + "packages/**/*.js": [ + "node ./bin/update-readmes.js" + ] + }, + "wp-env": { + "plugin-dir": "gutenberg", + "plugin-name": "Gutenberg", + "docker-template": "./bin/docker-compose.override.yml.template", + "welcome-logo": [ + ",⁻⁻⁻· . |", + "| ،⁓’. . |--- ,---. ,---. |---. ,---. ,---. ,---.", + "| | | | | |---' | | | | |---' | | |", + "`---' `---' `---’ `---’ ' ` `---' `---’ ` `---|", + " `---'" + ], + "welcome-build-command": "npm run dev" + } +} diff --git a/gb-src/packages/README.md b/gb-src/packages/README.md new file mode 100644 index 0000000000000..b0f7d8562cdb3 --- /dev/null +++ b/gb-src/packages/README.md @@ -0,0 +1,194 @@ +# Managing Packages + +This repository uses [lerna] to manage WordPress modules and publish them as packages to [npm]. + +## Creating a New Package + +When creating a new package, you need to provide at least the following: + +1. `package.json` based on the template: + ```json + { + "name": "@wordpress/package-name", + "version": "1.0.0-beta.0", + "description": "Package description.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/package-name/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } + } + ``` + This assumes that your code is located in the `src` folder and will be transpiled with `Babel`. +2. `.npmrc` file which disables creating `package-lock.json` file for the package: + ``` + package-lock=false + ``` +3. `README.md` file containing at least: + - Package name + - Package description + - Installation details + - Usage example + - `Code is Poetry` logo (`<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>`) + +## Managing Dependencies + +There are two types of dependencies that you might want to add to one of the existing WordPress packages. + +### Production Dependencies + +Production dependencies are stored in the `dependencies` section of the package’s `package.json` file. + +#### Adding New Dependencies + +The simplest way to add a production dependency to one of the packages is to run a very convenient [lerna add](https://github.com/lerna/lerna/tree/master/commands/add#readme) command from the root of the project. + +_Example:_ + +```bash +lerna add lodash packages/a11y +``` + +This command adds the latest version of `lodash` as a dependency to the `@wordpress/a11y` package, which is located in `packages/a11y` folder. + +#### Removing Existing Dependencies + +Removing a dependency from one of the WordPress packages requires some manual work. You need to remove the line in the corresponding `dependencies` section of the `package.json` file. + +_Example:_ + +```diff ++++ b/packages/scripts/package.json +@@ -43,7 +43,6 @@ + "check-node-version": "^3.1.1", + "cross-spawn": "^5.1.0", + "eslint": "^5.16.0", +- "jest": "^24.7.1", + "jest-puppeteer": "^4.0.0", + "minimist": "^1.2.0", + "npm-package-json-lint": "^3.6.0", +``` + +Next, you need to run `npm install` in the root of the project to ensure that `package-lock.json` file gets properly regenerated. + +#### Updating Existing Dependencies + +This is the most confusing part of working with [lerna] which causes a lot of hassles for contributors. The most successful strategy so far is to do the following: + 1. First, remove the existing dependency as described in the previous section. + 2. Next, add the same dependency back as described in the first section of this chapter. This time it wil get the latest version applied unless you enforce a different version explicitly. + +### Development Dependencies + +In contrast to production dependencies, development dependencies shouldn't be stored in individual WordPress packages. Instead they should be installed in the project's `package.json` file using the usual `npm install` command. In effect, all development tools are configured to work with every package at the same time to ensure they share the same characteristics and integrate correctly with each other. + +_Example:_ + +```bash +npm install glob --save-dev +``` + +This commands adds the latest version of `glob` as a development dependency to the `package.json` file. It has to be executed from the root of the project. + +## Maintaining Changelogs + +In maintaining dozens of npm packages, it can be tough to keep track of changes. To simplify the release process, each package includes a `CHANGELOG.md` file which details all published releases and the unreleased ("Master") changes, if any exist. + +For each pull request, you should always include relevant changes in a "Master" heading at the top of the file. You should add the heading if it doesn't already exist. + +_Example:_ + +```md +## Master + +### Bug Fix + +- Fixed an off-by-one error with the `sum` function. +``` + +There are a number of common release subsections you can follow. Each is intended to align to a specific meaning in the context of the [Semantic Versioning (`semver`) specification](https://semver.org/) the project adheres to. It is important that you describe your changes accurately, since this is used in the packages release process to help determine the version of the next release. + +- "Breaking Change" - A backwards-incompatible change which requires specific attention of the impacted developers to reconcile (requires a major version bump). +- "New Feature" - The addition of a new backwards-compatible function or feature to the existing public API (requires a minor verison bump). +- "Enhancement" - Backwards-compatible improvements to existing functionality (requires a minor version bump). +- "Bug Fix" - Resolutions to existing buggy behavior (requires a patch version bump). +- "Internal" - Changes which do not have an impact on the public interface or behavior of the module (requires a patch version bump). + +While other section naming can be used when appropriate, it's important that are expressed clearly to avoid confusion for both the packages releaser and third-party consumers. + +When in doubt, refer to [Semantic Versioning specification](https://semver.org/). + +If you are publishing new versions of packages, note that there are versioning recommendations outlined in the [Gutenberg Release Process document](https://github.com/WordPress/gutenberg/blob/master/docs/contributors/release.md) which prescribe _minimum_ version bumps for specific types of releases. The chosen version should be the greater of the two between the semantic versioning and Gutenberg release minimum version bumps. + +## Releasing Packages + +Lerna automatically releases all outdated packages. To check which packages are outdated and will be released, type `npm run publish:check`. + +If you have the ability to publish packages, you _must_ have [2FA enabled](https://docs.npmjs.com/getting-started/using-two-factor-authentication) on your [npm account][npm]. + +### Before Releasing + +Confirm that you're logged in to [npm], by running `npm whoami`. If you're not logged in, run `npm adduser` to login. + +If you're publishing a new package, ensure that its `package.json` file contains the correct `publishConfig` settings: + +```json +{ + "publishConfig": { + "access": "public" + } +} +``` + +You can check your package configs by running `npm run lint-pkg-json`. + +### Development Release + +Run the following command to release a dev version of the outdated packages. + +```bash +npm run publish:dev +``` + +Lerna will ask you which version number you want to choose for each package. For a `dev` release, you'll more likely want to choose the "prerelease" option. Repeat the same for all the outdated packages and confirm your version updates. + +Lerna will then publish to [npm], commit the `package.json` changes and create the git tags. + +### Production Release + +To release a production version for the outdated packages, run the following command: + +```bash +npm run publish:prod +``` + +Choose the correct version based on `CHANGELOG.md` files, confirm your choices and let Lerna do its magic. + +### Legacy Patch Release + +To release a patch for the older major or minor version of packages, run the following command: + +```bash +npm run publish:legacy +``` + +This is usually necessary when adding bug fixes or security patches to the earlier versions of WordPress. + +[lerna]: https://lerna.js.org/ +[npm]: https://www.npmjs.com/ diff --git a/gb-src/packages/a11y/.npmrc b/gb-src/packages/a11y/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/a11y/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/a11y/CHANGELOG.md b/gb-src/packages/a11y/CHANGELOG.md new file mode 100644 index 0000000000000..6d3354f059cc8 --- /dev/null +++ b/gb-src/packages/a11y/CHANGELOG.md @@ -0,0 +1,15 @@ +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +## 1.1.0 (2018-07-12) + +### New feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Polish + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) diff --git a/gb-src/packages/a11y/README.md b/gb-src/packages/a11y/README.md new file mode 100644 index 0000000000000..a45253197e7e6 --- /dev/null +++ b/gb-src/packages/a11y/README.md @@ -0,0 +1,74 @@ +# Accessibility (a11y) + +Accessibility utilities for WordPress. + +## Installation + +Install the module + +```bash +npm install @wordpress/a11y --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="setup" href="#setup">#</a> **setup** + +Create the live regions. + +<a name="speak" href="#speak">#</a> **speak** + +Allows you to easily announce dynamic interface updates to screen readers using ARIA live regions. +This module is inspired by the `speak` function in wp-a11y.js + +_Usage_ + +```js +import { speak } from '@wordpress/a11y'; + +// For polite messages that shouldn't interrupt what screen readers are currently announcing. +speak( 'The message you want to send to the ARIA live region' ); + +// For assertive messages that should interrupt what screen readers are currently announcing. +speak( 'The message you want to send to the ARIA live region', 'assertive' ); +``` + +_Parameters_ + +- _message_ `string`: The message to be announced by Assistive Technologies. +- _ariaLive_ `string`: Optional. The politeness level for aria-live. Possible values: polite or assertive. Default polite. + + +<!-- END TOKEN(Autogenerated API docs) --> + +### Background + +For context I'll quote [this article on WordPress.org](https://make.wordpress.org/accessibility/2015/04/15/let-wordpress-speak-new-in-wordpress-4-2/) by [@joedolson](https://github.com/joedolson): + +> #### Why. +> +> In modern web development, updating discrete regions of a screen with JavaScript is common. The use of AJAX responses in modern JavaScript-based User Interfaces allows web developers to create beautiful interfaces similar to Desktop applications that don’t require pages to reload or refresh. +> +> JavaScript can create great usability improvements for most users – but when content is updated dynamically, it has the potential to introduce accessibility issues. This is one of the steps you can take to handle that problem. +> +> #### What. +> +> When a portion of a page is updated with JavaScript, the update is usually highlighted with animation and bright colors, and is easy to see. But if you don’t have the ability to see the screen, you don’t know this has happened, unless the updated region is marked as an ARIA-live region. +> +> If this isn’t marked, there’s no notification for screen readers. But it’s also possible that all the region content will be announced after an update, if the ARIA live region is too large. You want to provide users with just a simple, concise message. +> +> #### How. +> +> That’s what `wp.a11y.speak()` is meant for. It’s a simple tool that creates and appends an ARIA live notifications area to the <body> element where developers can dispatch text messages. Assistive technologies will automatically announce any text change in this area. This ARIA live region has an ARIA role of “status” so it has an implicit aria-live value of polite and an implicit aria-atomic value of true. +> +> This means assistive technologies will notify users of updates but generally do not interrupt the current task, and updates take low priority. If you’re creating an application with higher priority updates (such as a notification that their current session is about to expire, for example), then you’ll want to create your own method with an aria-live value of assertive. + +## Browser support + +See <https://make.wordpress.org/design/handbook/design-guide/browser-support/> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/a11y/package.json b/gb-src/packages/a11y/package.json new file mode 100644 index 0000000000000..eb2f11f3a971d --- /dev/null +++ b/gb-src/packages/a11y/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/a11y", + "version": "2.5.1", + "description": "Accessibility (a11y) utilities for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "a11y", + "aria-live" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/a11y/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/a11y" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/dom-ready": "file:../dom-ready" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/a11y/src/addContainer.js b/gb-src/packages/a11y/src/addContainer.js new file mode 100644 index 0000000000000..38fa59001ec30 --- /dev/null +++ b/gb-src/packages/a11y/src/addContainer.js @@ -0,0 +1,36 @@ +/** + * Build the live regions markup. + * + * @param {string} ariaLive Optional. Value for the 'aria-live' attribute, default 'polite'. + * + * @return {Object} $container The ARIA live region jQuery object. + */ +const addContainer = function( ariaLive ) { + ariaLive = ariaLive || 'polite'; + + const container = document.createElement( 'div' ); + container.id = 'a11y-speak-' + ariaLive; + container.className = 'a11y-speak-region'; + + container.setAttribute( 'style', ( + 'position: absolute;' + + 'margin: -1px;' + + 'padding: 0;' + + 'height: 1px;' + + 'width: 1px;' + + 'overflow: hidden;' + + 'clip: rect(1px, 1px, 1px, 1px);' + + '-webkit-clip-path: inset(50%);' + + 'clip-path: inset(50%);' + + 'border: 0;' + + 'word-wrap: normal !important;' + ) ); + container.setAttribute( 'aria-live', ariaLive ); + container.setAttribute( 'aria-relevant', 'additions text' ); + container.setAttribute( 'aria-atomic', 'true' ); + + document.querySelector( 'body' ).appendChild( container ); + return container; +}; + +export default addContainer; diff --git a/gb-src/packages/a11y/src/clear.js b/gb-src/packages/a11y/src/clear.js new file mode 100644 index 0000000000000..94bab6ff85308 --- /dev/null +++ b/gb-src/packages/a11y/src/clear.js @@ -0,0 +1,11 @@ +/** + * Clear the a11y-speak-region elements. + */ +const clear = function() { + const regions = document.querySelectorAll( '.a11y-speak-region' ); + for ( let i = 0; i < regions.length; i++ ) { + regions[ i ].textContent = ''; + } +}; + +export default clear; diff --git a/gb-src/packages/a11y/src/filterMessage.js b/gb-src/packages/a11y/src/filterMessage.js new file mode 100644 index 0000000000000..27a48e85fec98 --- /dev/null +++ b/gb-src/packages/a11y/src/filterMessage.js @@ -0,0 +1,29 @@ +let previousMessage = ''; + +/** + * Filter the message to be announced to the screenreader. + * + * @param {string} message The message to be announced. + * + * @return {string} The filtered message. + */ +const filterMessage = function( message ) { + /* + * Strip HTML tags (if any) from the message string. Ideally, messages should + * be simple strings, carefully crafted for specific use with A11ySpeak. + * When re-using already existing strings this will ensure simple HTML to be + * stripped out and replaced with a space. Browsers will collapse multiple + * spaces natively. + */ + message = message.replace( /<[^<>]+>/g, ' ' ); + + if ( previousMessage === message ) { + message += '\u00A0'; + } + + previousMessage = message; + + return message; +}; + +export default filterMessage; diff --git a/gb-src/packages/a11y/src/index.js b/gb-src/packages/a11y/src/index.js new file mode 100644 index 0000000000000..525ea63b87968 --- /dev/null +++ b/gb-src/packages/a11y/src/index.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ +import addContainer from './addContainer'; +import clear from './clear'; +import filterMessage from './filterMessage'; + +/** + * Create the live regions. + */ +export const setup = function() { + let containerPolite = document.getElementById( 'a11y-speak-polite' ); + let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + if ( containerPolite === null ) { + containerPolite = addContainer( 'polite' ); + } + if ( containerAssertive === null ) { + containerAssertive = addContainer( 'assertive' ); + } +}; + +/** + * Run setup on domReady. + */ +domReady( setup ); + +/** + * Allows you to easily announce dynamic interface updates to screen readers using ARIA live regions. + * This module is inspired by the `speak` function in wp-a11y.js + * + * @param {string} message The message to be announced by Assistive Technologies. + * @param {string} ariaLive Optional. The politeness level for aria-live. Possible values: + * polite or assertive. Default polite. + * + * @example + * ```js + * import { speak } from '@wordpress/a11y'; + * + * // For polite messages that shouldn't interrupt what screen readers are currently announcing. + * speak( 'The message you want to send to the ARIA live region' ); + * + * // For assertive messages that should interrupt what screen readers are currently announcing. + * speak( 'The message you want to send to the ARIA live region', 'assertive' ); + * ``` + */ +export const speak = function( message, ariaLive ) { + // Clear previous messages to allow repeated strings being read out. + clear(); + + message = filterMessage( message ); + + const containerPolite = document.getElementById( 'a11y-speak-polite' ); + const containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + if ( containerAssertive && 'assertive' === ariaLive ) { + containerAssertive.textContent = message; + } else if ( containerPolite ) { + containerPolite.textContent = message; + } +}; diff --git a/gb-src/packages/a11y/src/index.native.js b/gb-src/packages/a11y/src/index.native.js new file mode 100644 index 0000000000000..b972687dbafce --- /dev/null +++ b/gb-src/packages/a11y/src/index.native.js @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import filterMessage from './filterMessage'; + +/** + * Update the ARIA live notification area text node. + * + * @param {string} message The message to be announced by Assistive Technologies. + * @param {string} ariaLive Optional. The politeness level for aria-live. Possible values: + * polite or assertive. Default polite. + */ +export const speak = function( message, ariaLive ) { + message = filterMessage( message ); + //TODO: Use native module to speak message + if ( 'assertive' === ariaLive ) { + + } else { + + } +}; diff --git a/gb-src/packages/a11y/src/test/addContainer.test.js b/gb-src/packages/a11y/src/test/addContainer.test.js new file mode 100644 index 0000000000000..e6c722078f74f --- /dev/null +++ b/gb-src/packages/a11y/src/test/addContainer.test.js @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ +import addContainer from '../addContainer'; + +describe( 'addContainer', () => { + describe( 'with polite param', () => { + it( 'should create an aria-live element with aria-live attr set to polite', () => { + const container = addContainer( 'polite' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-polite' ); + expect( container.getAttribute( 'style' ) ).not.toBeNull(); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'polite' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + } ); + } ); + + describe( 'with assertive param', () => { + it( 'should create an aria-live element with aria-live attr set to assertive', () => { + const container = addContainer( 'assertive' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-assertive' ); + expect( container.getAttribute( 'style' ) ).not.toBeNull(); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'assertive' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + } ); + } ); + + describe( 'without param', () => { + it( 'should default to creating an aria-live element with aria-live attr set to polite', () => { + const container = addContainer( 'polite' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-polite' ); + expect( container.getAttribute( 'style' ) ).not.toBeNull(); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'polite' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + } ); + } ); +} ); diff --git a/gb-src/packages/a11y/src/test/clear.test.js b/gb-src/packages/a11y/src/test/clear.test.js new file mode 100644 index 0000000000000..02438dda1a4ec --- /dev/null +++ b/gb-src/packages/a11y/src/test/clear.test.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import clear from '../clear'; + +describe( 'clear', () => { + it( 'should clear all a11y-speak-region elements', () => { + const container1 = document.createElement( 'div' ); + container1.className = 'a11y-speak-region'; + container1.textContent = 'not empty'; + document.querySelector( 'body' ).appendChild( container1 ); + + const container2 = document.createElement( 'div' ); + container2.className = 'a11y-speak-region'; + container2.textContent = 'not empty'; + document.querySelector( 'body' ).appendChild( container2 ); + + clear(); + expect( container1.textContent ).toBe( '' ); + expect( container2.textContent ).toBe( '' ); + } ); +} ); diff --git a/gb-src/packages/a11y/src/test/filterMessage.test.js b/gb-src/packages/a11y/src/test/filterMessage.test.js new file mode 100644 index 0000000000000..95302abb8d1ce --- /dev/null +++ b/gb-src/packages/a11y/src/test/filterMessage.test.js @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import filterMessage from '../filterMessage'; + +describe( 'filterMessage', () => { + describe( 'when a clean message is passed in', () => { + it( 'should return the message unfiltered', () => { + const actual = filterMessage( 'clean message.' ); + expect( actual ).toBe( 'clean message.' ); + } ); + } ); + + describe( 'when a message is passed in twice in a row', () => { + it( 'should add a space to the message to make sure it is announced again', () => { + filterMessage( 'repeated message.' ); + const actual = filterMessage( 'repeated message.' ); + expect( actual ).toBe( 'repeated message.' + '\u00A0' ); + } ); + } ); + + describe( 'when a message contains html tags', () => { + it( 'should strip the html tags and replace them with spaces', () => { + const actual = filterMessage( '<p>html paragraph</p>' ); + expect( actual ).toBe( ' html paragraph ' ); + } ); + } ); +} ); diff --git a/gb-src/packages/a11y/src/test/index.test.js b/gb-src/packages/a11y/src/test/index.test.js new file mode 100644 index 0000000000000..08c9489bed2ae --- /dev/null +++ b/gb-src/packages/a11y/src/test/index.test.js @@ -0,0 +1,112 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ +import { setup, speak } from '../'; +import clear from '../clear'; +import filterMessage from '../filterMessage'; + +jest.mock( '../clear', () => { + return jest.fn(); +} ); +jest.mock( '@wordpress/dom-ready', () => { + return jest.fn( ( callback ) => { + callback(); + } ); +} ); +jest.mock( '../filterMessage', () => { + return jest.fn( ( message ) => { + return message; + } ); +} ); + +describe( 'speak', () => { + let containerPolite = document.getElementById( 'a11y-speak-polite' ); + let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + beforeEach( () => { + containerPolite.textContent = ''; + containerAssertive.textContent = ''; + } ); + + describe( 'on import', () => { + it( 'should call domReady', () => { + expect( domReady ).toHaveBeenCalled(); + } ); + } ); + + describe( 'in default mode', () => { + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'default message' ); + expect( containerPolite.textContent ).toBe( 'default message' ); + expect( containerAssertive.textContent ).toBe( '' ); + expect( clear ).toHaveBeenCalled(); + expect( filterMessage ).toHaveBeenCalledWith( 'default message' ); + } ); + } ); + + describe( 'in assertive mode', () => { + it( 'should set the textcontent of the assertive aria-live region', () => { + speak( 'assertive message', 'assertive' ); + expect( containerPolite.textContent ).toBe( '' ); + expect( containerAssertive.textContent ).toBe( 'assertive message' ); + } ); + } ); + + describe( 'in explicit polite mode', () => { + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'polite message', 'polite' ); + expect( containerPolite.textContent ).toBe( 'polite message' ); + expect( containerAssertive.textContent ).toBe( '' ); + } ); + } ); + + describe( 'when somehow the assertive container is not present', () => { + beforeEach( () => { + document.getElementById( 'a11y-speak-assertive' ).remove(); + } ); + + afterEach( () => { + setup(); + containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + } ); + + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'message', 'assertive' ); + expect( containerPolite.textContent ).toBe( 'message' ); + expect( document.getElementById( 'a11y-speak-assertive' ) ).toBe( null ); + } ); + } ); + + describe( 'when somehow the both containers are not present', () => { + beforeEach( () => { + containerAssertive.remove(); + containerPolite.remove(); + } ); + + afterEach( () => { + setup(); + containerPolite = document.getElementById( 'a11y-speak-polite' ); + containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + } ); + + it( 'should set the textcontent of the polite aria-live region', () => { + expect( document.getElementById( 'a11y-speak-polite' ) ).toBe( null ); + expect( document.getElementById( 'a11y-speak-assertive' ) ).toBe( null ); + } ); + } ); + + describe( 'setup when the elements already exist', () => { + it( 'should not create the aria live regions again', () => { + const before = document.getElementsByClassName( 'a11y-speak-region' ).length; + setup(); + const after = document.getElementsByClassName( 'a11y-speak-region' ).length; + + expect( before ).toBe( after ); + } ); + } ); +} ); diff --git a/gb-src/packages/annotations/.npmrc b/gb-src/packages/annotations/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/annotations/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/annotations/CHANGELOG.md b/gb-src/packages/annotations/CHANGELOG.md new file mode 100644 index 0000000000000..cb15ba170bbe4 --- /dev/null +++ b/gb-src/packages/annotations/CHANGELOG.md @@ -0,0 +1,15 @@ +## 1.0.5 (2019-01-03) + +## 1.0.4 (2018-12-12) + +## 1.0.3 (2018-11-21) + +## 1.0.2 (2018-11-20) + +## 1.0.1 (2018-11-15) + +## 1.0.0 (2018-11-12) + +### New Features + +- Implement annotations API in the editor. diff --git a/gb-src/packages/annotations/README.md b/gb-src/packages/annotations/README.md new file mode 100644 index 0000000000000..af3a2263d1175 --- /dev/null +++ b/gb-src/packages/annotations/README.md @@ -0,0 +1,21 @@ +# Annotations + +Annotate content in the Gutenberg editor. + +## Installation + +Install the module + +```bash +npm install @wordpress/annotations --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Getting Started + +You need to include `wp-annotations` as a dependency of the JavaScript file in which you wish to use the Annotations API. + +## Usage + +[See this page for more detailed usage instructions](https://developer.wordpress.org/block-editor/developers/block-api/block-annotations). diff --git a/gb-src/packages/annotations/package.json b/gb-src/packages/annotations/package.json new file mode 100644 index 0000000000000..004b71d95a875 --- /dev/null +++ b/gb-src/packages/annotations/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wordpress/annotations", + "version": "1.7.4", + "description": "Annotate content in the Gutenberg editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "annotations" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/annotations/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/annotations" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/data": "file:../data", + "@wordpress/hooks": "file:../hooks", + "@wordpress/i18n": "file:../i18n", + "@wordpress/rich-text": "file:../rich-text", + "lodash": "^4.17.15", + "rememo": "^3.0.0", + "uuid": "^3.3.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/annotations/src/block/index.js b/gb-src/packages/annotations/src/block/index.js new file mode 100644 index 0000000000000..e2677bbf6a9b3 --- /dev/null +++ b/gb-src/packages/annotations/src/block/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { withSelect } from '@wordpress/data'; + +/** + * Adds annotation className to the block-list-block component. + * + * @param {Object} OriginalComponent The original BlockListBlock component. + * @return {Object} The enhanced component. + */ +const addAnnotationClassName = ( OriginalComponent ) => { + return withSelect( ( select, { clientId } ) => { + const annotations = select( 'core/annotations' ).__experimentalGetAnnotationsForBlock( clientId ); + + return { + className: annotations.map( ( annotation ) => { + return 'is-annotated-by-' + annotation.source; + } ).join( ' ' ), + }; + } )( OriginalComponent ); +}; + +addFilter( 'editor.BlockListBlock', 'core/annotations', addAnnotationClassName ); diff --git a/gb-src/packages/annotations/src/format/annotation.js b/gb-src/packages/annotations/src/format/annotation.js new file mode 100644 index 0000000000000..46dd6e279fa3b --- /dev/null +++ b/gb-src/packages/annotations/src/format/annotation.js @@ -0,0 +1,161 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { applyFormat, removeFormat } from '@wordpress/rich-text'; + +const FORMAT_NAME = 'core/annotation'; + +const ANNOTATION_ATTRIBUTE_PREFIX = 'annotation-text-'; +const STORE_KEY = 'core/annotations'; + +/** + * Applies given annotations to the given record. + * + * @param {Object} record The record to apply annotations to. + * @param {Array} annotations The annotation to apply. + * @return {Object} A record with the annotations applied. + */ +export function applyAnnotations( record, annotations = [] ) { + annotations.forEach( ( annotation ) => { + let { start, end } = annotation; + + if ( start > record.text.length ) { + start = record.text.length; + } + + if ( end > record.text.length ) { + end = record.text.length; + } + + const className = ANNOTATION_ATTRIBUTE_PREFIX + annotation.source; + const id = ANNOTATION_ATTRIBUTE_PREFIX + annotation.id; + + record = applyFormat( + record, + { + type: FORMAT_NAME, attributes: { + className, + id, + }, + }, + start, + end + ); + } ); + + return record; +} + +/** + * Removes annotations from the given record. + * + * @param {Object} record Record to remove annotations from. + * @return {Object} The cleaned record. + */ +export function removeAnnotations( record ) { + return removeFormat( record, 'core/annotation', 0, record.text.length ); +} + +/** + * Retrieves the positions of annotations inside an array of formats. + * + * @param {Array} formats Formats with annotations in there. + * @return {Object} ID keyed positions of annotations. + */ +function retrieveAnnotationPositions( formats ) { + const positions = {}; + + formats.forEach( ( characterFormats, i ) => { + characterFormats = characterFormats || []; + characterFormats = characterFormats.filter( ( format ) => format.type === FORMAT_NAME ); + characterFormats.forEach( ( format ) => { + let { id } = format.attributes; + id = id.replace( ANNOTATION_ATTRIBUTE_PREFIX, '' ); + + if ( ! positions.hasOwnProperty( id ) ) { + positions[ id ] = { + start: i, + }; + } + + // Annotations refer to positions between characters. + // Formats refer to the character themselves. + // So we need to adjust for that here. + positions[ id ].end = i + 1; + } ); + } ); + + return positions; +} + +/** + * Updates annotations in the state based on positions retrieved from RichText. + * + * @param {Array} annotations The annotations that are currently applied. + * @param {Array} positions The current positions of the given annotations. + * @param {Object} actions + * @param {Function} actions.removeAnnotation Function to remove an annotation from the state. + * @param {Function} actions.updateAnnotationRange Function to update an annotation range in the state. + */ +function updateAnnotationsWithPositions( annotations, positions, { removeAnnotation, updateAnnotationRange } ) { + annotations.forEach( ( currentAnnotation ) => { + const position = positions[ currentAnnotation.id ]; + // If we cannot find an annotation, delete it. + if ( ! position ) { + // Apparently the annotation has been removed, so remove it from the state: + // Remove... + removeAnnotation( currentAnnotation.id ); + return; + } + + const { start, end } = currentAnnotation; + if ( start !== position.start || end !== position.end ) { + updateAnnotationRange( currentAnnotation.id, position.start, position.end ); + } + } ); +} + +export const annotation = { + name: FORMAT_NAME, + title: __( 'Annotation' ), + tagName: 'mark', + className: 'annotation-text', + attributes: { + className: 'class', + id: 'id', + }, + edit() { + return null; + }, + __experimentalGetPropsForEditableTreePreparation( select, { richTextIdentifier, blockClientId } ) { + return { + annotations: select( STORE_KEY ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ), + }; + }, + __experimentalCreatePrepareEditableTree( { annotations } ) { + return ( formats, text ) => { + if ( annotations.length === 0 ) { + return formats; + } + + let record = { formats, text }; + record = applyAnnotations( record, annotations ); + return record.formats; + }; + }, + __experimentalGetPropsForEditableTreeChangeHandler( dispatch ) { + return { + removeAnnotation: dispatch( STORE_KEY ).__experimentalRemoveAnnotation, + updateAnnotationRange: dispatch( STORE_KEY ).__experimentalUpdateAnnotationRange, + }; + }, + __experimentalCreateOnChangeEditableValue( props ) { + return ( formats ) => { + const positions = retrieveAnnotationPositions( formats ); + const { removeAnnotation, updateAnnotationRange, annotations } = props; + + updateAnnotationsWithPositions( annotations, positions, { removeAnnotation, updateAnnotationRange } ); + }; + }, +}; diff --git a/gb-src/packages/annotations/src/format/index.js b/gb-src/packages/annotations/src/format/index.js new file mode 100644 index 0000000000000..1dccbbd5012a0 --- /dev/null +++ b/gb-src/packages/annotations/src/format/index.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { + registerFormatType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { annotation } from './annotation'; + +const { name, ...settings } = annotation; + +registerFormatType( name, settings ); diff --git a/gb-src/packages/annotations/src/index.js b/gb-src/packages/annotations/src/index.js new file mode 100644 index 0000000000000..ce64106bf903c --- /dev/null +++ b/gb-src/packages/annotations/src/index.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import './store'; +import './format'; +import './block'; + diff --git a/gb-src/packages/annotations/src/store/actions.js b/gb-src/packages/annotations/src/store/actions.js new file mode 100644 index 0000000000000..8135ca3e32066 --- /dev/null +++ b/gb-src/packages/annotations/src/store/actions.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +/** + * Adds an annotation to a block. + * + * The `block` attribute refers to a block ID that needs to be annotated. + * `isBlockAnnotation` controls whether or not the annotation is a block + * annotation. The `source` is the source of the annotation, this will be used + * to identity groups of annotations. + * + * The `range` property is only relevant if the selector is 'range'. + * + * @param {Object} annotation The annotation to add. + * @param {string} annotation.blockClientId The blockClientId to add the annotation to. + * @param {string} annotation.richTextIdentifier Identifier for the RichText instance the annotation applies to. + * @param {Object} annotation.range The range at which to apply this annotation. + * @param {number} annotation.range.start The offset where the annotation should start. + * @param {number} annotation.range.end The offset where the annotation should end. + * @param {string} annotation.[selector="range"] The way to apply this annotation. + * @param {string} annotation.[source="default"] The source that added the annotation. + * @param {string} annotation.[id] The ID the annotation should have. Generates a UUID by default. + * + * @return {Object} Action object. + */ +export function __experimentalAddAnnotation( { blockClientId, richTextIdentifier = null, range = null, selector = 'range', source = 'default', id = uuid() } ) { + const action = { + type: 'ANNOTATION_ADD', + id, + blockClientId, + richTextIdentifier, + source, + selector, + }; + + if ( selector === 'range' ) { + action.range = range; + } + + return action; +} + +/** + * Removes an annotation with a specific ID. + * + * @param {string} annotationId The annotation to remove. + * + * @return {Object} Action object. + */ +export function __experimentalRemoveAnnotation( annotationId ) { + return { + type: 'ANNOTATION_REMOVE', + annotationId, + }; +} + +/** + * Updates the range of an annotation. + * + * @param {string} annotationId ID of the annotation to update. + * @param {number} start The start of the new range. + * @param {number} end The end of the new range. + * + * @return {Object} Action object. + */ +export function __experimentalUpdateAnnotationRange( annotationId, start, end ) { + return { + type: 'ANNOTATION_UPDATE_RANGE', + annotationId, + start, + end, + }; +} + +/** + * Removes all annotations of a specific source. + * + * @param {string} source The source to remove. + * + * @return {Object} Action object. + */ +export function __experimentalRemoveAnnotationsBySource( source ) { + return { + type: 'ANNOTATION_REMOVE_SOURCE', + source, + }; +} diff --git a/gb-src/packages/annotations/src/store/index.js b/gb-src/packages/annotations/src/store/index.js new file mode 100644 index 0000000000000..1c7f27dccbc35 --- /dev/null +++ b/gb-src/packages/annotations/src/store/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; + +/** + * Module Constants + */ +const MODULE_KEY = 'core/annotations'; + +const store = registerStore( MODULE_KEY, { + reducer, + selectors, + actions, +} ); + +export default store; diff --git a/gb-src/packages/annotations/src/store/reducer.js b/gb-src/packages/annotations/src/store/reducer.js new file mode 100644 index 0000000000000..1f768a78ad215 --- /dev/null +++ b/gb-src/packages/annotations/src/store/reducer.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { get, isNumber, mapValues } from 'lodash'; + +/** + * Filters an array based on the predicate, but keeps the reference the same if + * the array hasn't changed. + * + * @param {Array} collection The collection to filter. + * @param {Function} predicate Function that determines if the item should stay + * in the array. + * @return {Array} Filtered array. + */ +function filterWithReference( collection, predicate ) { + const filteredCollection = collection.filter( predicate ); + + return collection.length === filteredCollection.length ? collection : filteredCollection; +} + +/** + * Verifies whether the given annotations is a valid annotation. + * + * @param {Object} annotation The annotation to verify. + * @return {boolean} Whether the given annotation is valid. + */ +function isValidAnnotationRange( annotation ) { + return isNumber( annotation.start ) && + isNumber( annotation.end ) && + annotation.start <= annotation.end; +} + +/** + * Reducer managing annotations. + * + * @param {Array} state The annotations currently shown in the editor. + * @param {Object} action Dispatched action. + * + * @return {Array} Updated state. + */ +export function annotations( state = {}, action ) { + switch ( action.type ) { + case 'ANNOTATION_ADD': + const blockClientId = action.blockClientId; + const newAnnotation = { + id: action.id, + blockClientId, + richTextIdentifier: action.richTextIdentifier, + source: action.source, + selector: action.selector, + range: action.range, + }; + + if ( newAnnotation.selector === 'range' && ! isValidAnnotationRange( newAnnotation.range ) ) { + return state; + } + + const previousAnnotationsForBlock = get( state, blockClientId, [] ); + + return { + ...state, + [ blockClientId ]: [ ...previousAnnotationsForBlock, newAnnotation ], + }; + + case 'ANNOTATION_REMOVE': + return mapValues( state, ( annotationsForBlock ) => { + return filterWithReference( annotationsForBlock, ( annotation ) => { + return annotation.id !== action.annotationId; + } ); + } ); + + case 'ANNOTATION_UPDATE_RANGE': + return mapValues( state, ( annotationsForBlock ) => { + let hasChangedRange = false; + + const newAnnotations = annotationsForBlock.map( ( annotation ) => { + if ( annotation.id === action.annotationId ) { + hasChangedRange = true; + return { + ...annotation, + range: { + start: action.start, + end: action.end, + }, + }; + } + + return annotation; + } ); + + return hasChangedRange ? newAnnotations : annotationsForBlock; + } ); + + case 'ANNOTATION_REMOVE_SOURCE': + return mapValues( state, ( annotationsForBlock ) => { + return filterWithReference( annotationsForBlock, ( annotation ) => { + return annotation.source !== action.source; + } ); + } ); + } + + return state; +} + +export default annotations; diff --git a/gb-src/packages/annotations/src/store/selectors.js b/gb-src/packages/annotations/src/store/selectors.js new file mode 100644 index 0000000000000..a39a315c92f90 --- /dev/null +++ b/gb-src/packages/annotations/src/store/selectors.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import { get, flatMap } from 'lodash'; + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation, as in a connected or + * other pure component which performs `shouldComponentUpdate` check on props. + * This should be used as a last resort, since the normalized data should be + * maintained by the reducer result in state. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Returns the annotations for a specific client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId The ID of the block to get the annotations for. + * + * @return {Array} The annotations applicable to this block. + */ +export const __experimentalGetAnnotationsForBlock = createSelector( + ( state, blockClientId ) => { + return get( state, blockClientId, [] ).filter( ( annotation ) => { + return annotation.selector === 'block'; + } ); + }, + ( state, blockClientId ) => [ + get( state, blockClientId, EMPTY_ARRAY ), + ] +); + +export const __experimentalGetAllAnnotationsForBlock = function( state, blockClientId ) { + return get( state, blockClientId, EMPTY_ARRAY ); +}; + +/** + * Returns the annotations that apply to the given RichText instance. + * + * Both a blockClientId and a richTextIdentifier are required. This is because + * a block might have multiple `RichText` components. This does mean that every + * block needs to implement annotations itself. + * + * @param {Object} state Editor state. + * @param {string} blockClientId The client ID for the block. + * @param {string} richTextIdentifier Unique identifier that identifies the given RichText. + * @return {Array} All the annotations relevant for the `RichText`. + */ +export const __experimentalGetAnnotationsForRichText = createSelector( + ( state, blockClientId, richTextIdentifier ) => { + return get( state, blockClientId, [] ).filter( ( annotation ) => { + return annotation.selector === 'range' && + richTextIdentifier === annotation.richTextIdentifier; + } ).map( ( annotation ) => { + const { range, ...other } = annotation; + + return { + ...range, + ...other, + }; + } ); + }, + ( state, blockClientId ) => [ + get( state, blockClientId, EMPTY_ARRAY ), + ] +); + +/** + * Returns all annotations in the editor state. + * + * @param {Object} state Editor state. + * @return {Array} All annotations currently applied. + */ +export function __experimentalGetAnnotations( state ) { + return flatMap( state, ( annotations ) => { + return annotations; + } ); +} diff --git a/gb-src/packages/annotations/src/store/test/reducer.js b/gb-src/packages/annotations/src/store/test/reducer.js new file mode 100644 index 0000000000000..190795bd8a98c --- /dev/null +++ b/gb-src/packages/annotations/src/store/test/reducer.js @@ -0,0 +1,189 @@ +/** + * Internal dependencies + */ +import { annotations } from '../reducer'; + +describe( 'annotations', () => { + const initialState = {}; + + it( 'returns all annotations and annotation IDs per block', () => { + const state = annotations( undefined, {} ); + + expect( state ).toEqual( initialState ); + } ); + + it( 'returns a state with an annotation that has been added', () => { + const state = annotations( undefined, { + type: 'ANNOTATION_ADD', + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + } ); + + expect( state ).toEqual( { + blockClientId: [ { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + } ], + } ); + } ); + + it( 'allows an annotation to be removed', () => { + const state = annotations( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + }, + ], + }, { + type: 'ANNOTATION_REMOVE', + annotationId: 'annotationId', + } ); + + expect( state ).toEqual( { blockClientId: [] } ); + } ); + + it( 'allows an annotation to be removed by its source', () => { + const annotation1 = { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + }; + const annotation2 = { + id: 'annotationId2', + blockClientId: 'blockClientId2', + richTextIdentifier: 'identifier2', + source: 'other-source', + selector: 'block', + }; + const state = annotations( { + blockClientId: [ + annotation1, + ], + blockClientId2: [ + annotation2, + ], + }, { + type: 'ANNOTATION_REMOVE_SOURCE', + source: 'default', + } ); + + expect( state ).toEqual( { + blockClientId: [], + blockClientId2: [ annotation2 ], + } ); + } ); + + it( 'allows a range selector', () => { + const state = annotations( undefined, { + type: 'ANNOTATION_ADD', + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + } ); + + expect( state ).toEqual( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + }, + ], + } ); + } ); + + it( 'moves annotations when said action is dispatched', () => { + const state = annotations( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + }, + ], + }, { + type: 'ANNOTATION_UPDATE_RANGE', + annotationId: 'annotationId', + start: 50, + end: 75, + } ); + + expect( state ).toEqual( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 50, + end: 75, + }, + }, + ], + } ); + } ); + + it( 'rejects invalid annotations', () => { + let state = annotations( undefined, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 5, + end: 4, + }, + } ); + state = annotations( state, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 'not a number', + end: 100, + }, + } ); + state = annotations( state, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 100, + end: 'not a number', + }, + } ); + + expect( state ).toEqual( initialState ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/.npmrc b/gb-src/packages/api-fetch/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/api-fetch/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/api-fetch/CHANGELOG.md b/gb-src/packages/api-fetch/CHANGELOG.md new file mode 100644 index 0000000000000..dc7dcdeb3defe --- /dev/null +++ b/gb-src/packages/api-fetch/CHANGELOG.md @@ -0,0 +1,40 @@ +## 3.0.0 (2019-03-06) + +### Breaking Changes + +- A created nonce middleware will no longer automatically listen for `heartbeat.tick` actions. Assign to the new `nonce` middleware property instead. + +### New Feature + +- The function returned by `createNonceMiddleware` includes an assignable `nonce` property corresponding to the active nonce to be used. +- Default fetch handler can be overridden with a custom fetch handler + +## 2.2.6 (2018-12-12) + +## 2.2.5 (2018-11-20) + +## 2.2.4 (2018-11-15) + +## 2.2.3 (2018-11-12) + +## 2.2.2 (2018-11-03) + +## 2.2.1 (2018-10-30) + +## 2.2.0 (2018-10-29) + +### New Feature + +- Always request data in the user's locale ([#10862](https://github.com/WordPress/gutenberg/pull/10862)). + +## 2.1.0 (2018-10-22) + +### New Feature + +- Support `per_page=-1` paginated requests. + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/api-fetch/README.md b/gb-src/packages/api-fetch/README.md new file mode 100644 index 0000000000000..c5b6b190ddbe3 --- /dev/null +++ b/gb-src/packages/api-fetch/README.md @@ -0,0 +1,113 @@ +# API Fetch + +Utility to make WordPress REST API requests. It's a wrapper around `window.fetch`. + +## Installation + +Install the module + +```bash +npm install @wordpress/api-fetch --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```js +import apiFetch from '@wordpress/api-fetch'; + +apiFetch( { path: '/wp/v2/posts' } ).then( posts => { + console.log( posts ); +} ); +``` + +### Options + +`apiFetch` supports and passes through all [options of the `fetch` global](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch). + +Additionally, the following options are available: + +#### `path` (`string`) + +Shorthand to be used in place of `url`, appended to the REST API root URL for the current site. + +#### `url` (`string`) + +Absolute URL to the endpoint from which to fetch. + +#### `parse` (`boolean`, default `true`) + +Unlike `fetch`, the `Promise` return value of `apiFetch` will resolve to the parsed JSON result. Disable this behavior by passing `parse` as `false`. + +#### `data` (`object`) + +Shorthand to be used in place of `body`, accepts an object value to be stringified to JSON. + +### Middlewares + +the `api-fetch` package supports middlewares. Middlewares are functions you can use to wrap the `apiFetch` calls to perform any pre/post process to the API requests. + +**Example** + +```js +import apiFetch from '@wordpress/api-fetch'; + +apiFetch.use( ( options, next ) => { + const start = Date.now(); + const result = next( options ); + result.then( () => { + console.log( 'The request took ' + Date.now() - start ); + } ); + return result; +} ); +``` + +### Built-in middlewares + +The `api-fetch` package provides built-in middlewares you can use to provide a `nonce` and a custom `rootURL`. + +**Nonce middleware** + +```js +import apiFetch from '@wordpress/api-fetch'; + +const nonce = "nonce value"; +apiFetch.use( apiFetch.createNonceMiddleware( nonce ) ); +``` + +The function returned by `createNonceMiddleware` includes a `nonce` property corresponding to the actively used nonce. You may also assign to this property if you have a fresh nonce value to use. + +**Root URL middleware** + +```js +import apiFetch from '@wordpress/api-fetch'; + +const rootURL = "http://my-wordpress-site/wp-json/"; +apiFetch.use( apiFetch.createRootURLMiddleware( rootURL ) ); +``` + +### Custom fetch handler + +The `api-fetch` package uses `window.fetch` for making the requests but you can use a custom fetch handler by using the `setFetchHandler` method. The custom fetch handler will receive the `options` passed to the `apiFetch` calls. + +**Example** + +The example below uses a custom fetch handler for making all the requests with [`axios`](https://github.com/axios/axios). + +```js +import apiFetch from '@wordpress/api-fetch'; +import axios from 'axios'; + +apiFetch.setFetchHandler( ( options ) => { + const { url, path, data, method } = options; + + return axios( { + url: url || path, + method, + data, + } ); +} ); +``` + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/api-fetch/package.json b/gb-src/packages/api-fetch/package.json new file mode 100644 index 0000000000000..4d3b907e1ce5c --- /dev/null +++ b/gb-src/packages/api-fetch/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wordpress/api-fetch", + "version": "3.6.4", + "description": "Utility to make WordPress REST API requests.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "rest-api", + "fetch" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/api-fetch/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/api-fetch" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/i18n": "file:../i18n", + "@wordpress/url": "file:../url" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/api-fetch/src/index.js b/gb-src/packages/api-fetch/src/index.js new file mode 100644 index 0000000000000..dc7dda91fa1e6 --- /dev/null +++ b/gb-src/packages/api-fetch/src/index.js @@ -0,0 +1,143 @@ +/** + * Internal dependencies + */ +import createNonceMiddleware from './middlewares/nonce'; +import createRootURLMiddleware from './middlewares/root-url'; +import createPreloadingMiddleware from './middlewares/preloading'; +import fetchAllMiddleware from './middlewares/fetch-all-middleware'; +import namespaceEndpointMiddleware from './middlewares/namespace-endpoint'; +import httpV1Middleware from './middlewares/http-v1'; +import userLocaleMiddleware from './middlewares/user-locale'; +import mediaUploadMiddleware from './middlewares/media-upload'; +import { parseResponseAndNormalizeError, parseAndThrowError } from './utils/response'; + +/** + * Default set of header values which should be sent with every request unless + * explicitly provided through apiFetch options. + * + * @type {Object} + */ +const DEFAULT_HEADERS = { + // The backend uses the Accept header as a condition for considering an + // incoming request as a REST request. + // + // See: https://core.trac.wordpress.org/ticket/44534 + Accept: 'application/json, */*;q=0.1', +}; + +/** + * Default set of fetch option values which should be sent with every request + * unless explicitly provided through apiFetch options. + * + * @type {Object} + */ +const DEFAULT_OPTIONS = { + credentials: 'include', +}; + +const middlewares = [ + userLocaleMiddleware, + namespaceEndpointMiddleware, + httpV1Middleware, + fetchAllMiddleware, +]; + +function registerMiddleware( middleware ) { + middlewares.unshift( middleware ); +} + +const checkStatus = ( response ) => { + if ( response.status >= 200 && response.status < 300 ) { + return response; + } + + throw response; +}; + +const defaultFetchHandler = ( nextOptions ) => { + const { url, path, data, parse = true, ...remainingOptions } = nextOptions; + let { body, headers } = nextOptions; + + // Merge explicitly-provided headers with default values. + headers = { ...DEFAULT_HEADERS, ...headers }; + + // The `data` property is a shorthand for sending a JSON body. + if ( data ) { + body = JSON.stringify( data ); + headers[ 'Content-Type' ] = 'application/json'; + } + + const responsePromise = window.fetch( + url || path, + { + ...DEFAULT_OPTIONS, + ...remainingOptions, + body, + headers, + } + ); + + return responsePromise + .then( checkStatus ) + .catch( ( response ) => parseAndThrowError( response, parse ) ) + .then( ( response ) => parseResponseAndNormalizeError( response, parse ) ); +}; + +let fetchHandler = defaultFetchHandler; + +/** + * Defines a custom fetch handler for making the requests that will override + * the default one using window.fetch + * + * @param {Function} newFetchHandler The new fetch handler + */ +function setFetchHandler( newFetchHandler ) { + fetchHandler = newFetchHandler; +} + +function apiFetch( options ) { + const steps = [ ...middlewares, fetchHandler ]; + + const createRunStep = ( index ) => ( workingOptions ) => { + const step = steps[ index ]; + if ( index === steps.length - 1 ) { + return step( workingOptions ); + } + + const next = createRunStep( index + 1 ); + return step( workingOptions, next ); + }; + + return new Promise( function( resolve, reject ) { + createRunStep( 0 )( options ) + .then( resolve ) + .catch( ( error ) => { + if ( error.code !== 'rest_cookie_invalid_nonce' ) { + return reject( error ); + } + + // If the nonce is invalid, refresh it and try again. + window.fetch( apiFetch.nonceEndpoint ) + .then( checkStatus ) + .then( ( data ) => data.text() ) + .then( ( text ) => { + apiFetch.nonceMiddleware.nonce = text; + apiFetch( options ) + .then( resolve ) + .catch( reject ); + } ) + .catch( reject ); + } ); + } ); +} + +apiFetch.use = registerMiddleware; +apiFetch.setFetchHandler = setFetchHandler; + +apiFetch.createNonceMiddleware = createNonceMiddleware; +apiFetch.createPreloadingMiddleware = createPreloadingMiddleware; +apiFetch.createRootURLMiddleware = createRootURLMiddleware; +apiFetch.fetchAllMiddleware = fetchAllMiddleware; +apiFetch.mediaUploadMiddleware = mediaUploadMiddleware; + +export default apiFetch; diff --git a/gb-src/packages/api-fetch/src/middlewares/fetch-all-middleware.js b/gb-src/packages/api-fetch/src/middlewares/fetch-all-middleware.js new file mode 100644 index 0000000000000..5bc17b5413d32 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/fetch-all-middleware.js @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +// Apply query arguments to both URL and Path, whichever is present. +const modifyQuery = ( { path, url, ...options }, queryArgs ) => ( { + ...options, + url: url && addQueryArgs( url, queryArgs ), + path: path && addQueryArgs( path, queryArgs ), +} ); + +// Duplicates parsing functionality from apiFetch. +const parseResponse = ( response ) => response.json ? + response.json() : + Promise.reject( response ); + +const parseLinkHeader = ( linkHeader ) => { + if ( ! linkHeader ) { + return {}; + } + const match = linkHeader.match( /<([^>]+)>; rel="next"/ ); + return match ? { + next: match[ 1 ], + } : {}; +}; + +const getNextPageUrl = ( response ) => { + const { next } = parseLinkHeader( response.headers.get( 'link' ) ); + return next; +}; + +const requestContainsUnboundedQuery = ( options ) => { + const pathIsUnbounded = options.path && options.path.indexOf( 'per_page=-1' ) !== -1; + const urlIsUnbounded = options.url && options.url.indexOf( 'per_page=-1' ) !== -1; + return pathIsUnbounded || urlIsUnbounded; +}; + +// The REST API enforces an upper limit on the per_page option. To handle large +// collections, apiFetch consumers can pass `per_page=-1`; this middleware will +// then recursively assemble a full response array from all available pages. +const fetchAllMiddleware = async ( options, next ) => { + if ( options.parse === false ) { + // If a consumer has opted out of parsing, do not apply middleware. + return next( options ); + } + if ( ! requestContainsUnboundedQuery( options ) ) { + // If neither url nor path is requesting all items, do not apply middleware. + return next( options ); + } + + // Retrieve requested page of results. + const response = await next( { + ...modifyQuery( options, { + per_page: 100, + } ), + // Ensure headers are returned for page 1. + parse: false, + } ); + + const results = await parseResponse( response ); + + if ( ! Array.isArray( results ) ) { + // We have no reliable way of merging non-array results. + return results; + } + + let nextPage = getNextPageUrl( response ); + + if ( ! nextPage ) { + // There are no further pages to request. + return results; + } + + // Iteratively fetch all remaining pages until no "next" header is found. + let mergedResults = [].concat( results ); + while ( nextPage ) { + const nextResponse = await next( { + ...options, + // Ensure the URL for the next page is used instead of any provided path. + path: undefined, + url: nextPage, + // Ensure we still get headers so we can identify the next page. + parse: false, + } ); + const nextResults = await parseResponse( nextResponse ); + mergedResults = mergedResults.concat( nextResults ); + nextPage = getNextPageUrl( nextResponse ); + } + return mergedResults; +}; + +export default fetchAllMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/http-v1.js b/gb-src/packages/api-fetch/src/middlewares/http-v1.js new file mode 100644 index 0000000000000..d7771bf6f9b1c --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/http-v1.js @@ -0,0 +1,50 @@ +/** + * Set of HTTP methods which are eligible to be overridden. + * + * @type {Set} + */ +const OVERRIDE_METHODS = new Set( [ + 'PATCH', + 'PUT', + 'DELETE', +] ); + +/** + * Default request method. + * + * "A request has an associated method (a method). Unless stated otherwise it + * is `GET`." + * + * @see https://fetch.spec.whatwg.org/#requests + * + * @type {string} + */ +const DEFAULT_METHOD = 'GET'; + +/** + * API Fetch middleware which overrides the request method for HTTP v1 + * compatibility leveraging the REST API X-HTTP-Method-Override header. + * + * @param {Object} options Fetch options. + * @param {Function} next [description] + * + * @return {*} The evaluated result of the remaining middleware chain. + */ +function httpV1Middleware( options, next ) { + const { method = DEFAULT_METHOD } = options; + if ( OVERRIDE_METHODS.has( method.toUpperCase() ) ) { + options = { + ...options, + headers: { + ...options.headers, + 'X-HTTP-Method-Override': method, + 'Content-Type': 'application/json', + }, + method: 'POST', + }; + } + + return next( options, next ); +} + +export default httpV1Middleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/media-upload.js b/gb-src/packages/api-fetch/src/middlewares/media-upload.js new file mode 100644 index 0000000000000..6772aaf3ded73 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/media-upload.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + parseAndThrowError, + parseResponseAndNormalizeError, +} from '../utils/response'; + +/** + * Middleware handling media upload failures and retries. + * + * @param {Object} options Fetch options. + * @param {Function} next [description] + * + * @return {*} The evaluated result of the remaining middleware chain. + */ +function mediaUploadMiddleware( options, next ) { + const isMediaUploadRequest = + ( options.path && options.path.indexOf( '/wp/v2/media' ) !== -1 ) || + ( options.url && options.url.indexOf( '/wp/v2/media' ) !== -1 ); + + if ( ! isMediaUploadRequest ) { + return next( options, next ); + } + let retries = 0; + const maxRetries = 5; + + const postProcess = ( attachmentId ) => { + retries++; + return next( { + path: `/wp/v2/media/${ attachmentId }/post-process`, + method: 'POST', + data: { action: 'create-image-subsizes' }, + parse: false, + } ) + .catch( () => { + if ( retries < maxRetries ) { + return postProcess( attachmentId ); + } + next( { + path: `/wp/v2/media/${ attachmentId }?force=true`, + method: 'DELETE', + } ); + + return Promise.reject(); + } ); + }; + + return next( { ...options, parse: false } ) + .catch( ( response ) => { + const attachmentId = response.headers.get( 'x-wp-upload-attachment-id' ); + if ( response.status >= 500 && response.status < 600 && attachmentId ) { + return postProcess( attachmentId ).catch( () => { + if ( options.parse !== false ) { + return Promise.reject( { + code: 'post_process', + message: __( 'Media upload failed. If this is a photo or a large image, please scale it down and try again.' ), + } ); + } + + return Promise.reject( response ); + } ); + } + return parseAndThrowError( response, options.parse ); + } ) + .then( ( response ) => parseResponseAndNormalizeError( response, options.parse ) ); +} + +export default mediaUploadMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/namespace-endpoint.js b/gb-src/packages/api-fetch/src/middlewares/namespace-endpoint.js new file mode 100644 index 0000000000000..c6ee6c67b4afb --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/namespace-endpoint.js @@ -0,0 +1,27 @@ +const namespaceAndEndpointMiddleware = ( options, next ) => { + let path = options.path; + let namespaceTrimmed, endpointTrimmed; + + if ( + typeof options.namespace === 'string' && + typeof options.endpoint === 'string' + ) { + namespaceTrimmed = options.namespace.replace( /^\/|\/$/g, '' ); + endpointTrimmed = options.endpoint.replace( /^\//, '' ); + if ( endpointTrimmed ) { + path = namespaceTrimmed + '/' + endpointTrimmed; + } else { + path = namespaceTrimmed; + } + } + + delete options.namespace; + delete options.endpoint; + + return next( { + ...options, + path, + } ); +}; + +export default namespaceAndEndpointMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/nonce.js b/gb-src/packages/api-fetch/src/middlewares/nonce.js new file mode 100644 index 0000000000000..e9baf2e785b5d --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/nonce.js @@ -0,0 +1,27 @@ +function createNonceMiddleware( nonce ) { + function middleware( options, next ) { + const { headers = {} } = options; + + // If an 'X-WP-Nonce' header (or any case-insensitive variation + // thereof) was specified, no need to add a nonce header. + for ( const headerName in headers ) { + if ( headerName.toLowerCase() === 'x-wp-nonce' ) { + return next( options ); + } + } + + return next( { + ...options, + headers: { + ...headers, + 'X-WP-Nonce': middleware.nonce, + }, + } ); + } + + middleware.nonce = nonce; + + return middleware; +} + +export default createNonceMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/preloading.js b/gb-src/packages/api-fetch/src/middlewares/preloading.js new file mode 100644 index 0000000000000..4de356243b736 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/preloading.js @@ -0,0 +1,49 @@ +const createPreloadingMiddleware = ( preloadedData ) => ( options, next ) => { + function getStablePath( path ) { + const splitted = path.split( '?' ); + const query = splitted[ 1 ]; + const base = splitted[ 0 ]; + if ( ! query ) { + return base; + } + + // 'b=1&c=2&a=5' + return base + '?' + query + // [ 'b=1', 'c=2', 'a=5' ] + .split( '&' ) + // [ [ 'b, '1' ], [ 'c', '2' ], [ 'a', '5' ] ] + .map( function( entry ) { + return entry.split( '=' ); + } ) + // [ [ 'a', '5' ], [ 'b, '1' ], [ 'c', '2' ] ] + .sort( function( a, b ) { + return a[ 0 ].localeCompare( b[ 0 ] ); + } ) + // [ 'a=5', 'b=1', 'c=2' ] + .map( function( pair ) { + return pair.join( '=' ); + } ) + // 'a=5&b=1&c=2' + .join( '&' ); + } + + const { parse = true } = options; + if ( typeof options.path === 'string' ) { + const method = options.method || 'GET'; + const path = getStablePath( options.path ); + + if ( parse && 'GET' === method && preloadedData[ path ] ) { + return Promise.resolve( preloadedData[ path ].body ); + } else if ( + 'OPTIONS' === method && + preloadedData[ method ] && + preloadedData[ method ][ path ] + ) { + return Promise.resolve( preloadedData[ method ][ path ] ); + } + } + + return next( options ); +}; + +export default createPreloadingMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/root-url.js b/gb-src/packages/api-fetch/src/middlewares/root-url.js new file mode 100644 index 0000000000000..92b50523642fc --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/root-url.js @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import namespaceAndEndpointMiddleware from './namespace-endpoint'; + +const createRootURLMiddleware = ( rootURL ) => ( options, next ) => { + return namespaceAndEndpointMiddleware( options, ( optionsWithPath ) => { + let url = optionsWithPath.url; + let path = optionsWithPath.path; + let apiRoot; + + if ( typeof path === 'string' ) { + apiRoot = rootURL; + + if ( -1 !== rootURL.indexOf( '?' ) ) { + path = path.replace( '?', '&' ); + } + + path = path.replace( /^\//, '' ); + + // API root may already include query parameter prefix if site is + // configured to use plain permalinks. + if ( 'string' === typeof apiRoot && -1 !== apiRoot.indexOf( '?' ) ) { + path = path.replace( '?', '&' ); + } + + url = apiRoot + path; + } + + return next( { + ...optionsWithPath, + url, + } ); + } ); +}; + +export default createRootURLMiddleware; diff --git a/gb-src/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js b/gb-src/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js new file mode 100644 index 0000000000000..b73eeba15e14b --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import fetchAllMiddleware from '../fetch-all-middleware'; + +describe( 'Fetch All Middleware', () => { + it( 'should defer with the same options to the next middleware', async () => { + expect.hasAssertions(); + const originalOptions = { path: '/posts' }; + const next = ( options ) => { + expect( options ).toBe( originalOptions ); + return Promise.resolve( 'ok' ); + }; + + await fetchAllMiddleware( originalOptions, next ); + } ); + + it( 'should paginate the request', async () => { + expect.hasAssertions(); + const originalOptions = { url: '/posts?per_page=-1' }; + let counter = 1; + const next = ( options ) => { + if ( counter === 1 ) { + expect( options.url ).toBe( '/posts?per_page=100' ); + } else { + expect( options.url ).toBe( '/posts?per_page=100&page=2' ); + } + const response = Promise.resolve( { + status: 200, + headers: { + get() { + return options.url === '/posts?per_page=100' ? + '</posts?per_page=100&page=2>; rel="next"' : + ''; + }, + }, + json() { + return Promise.resolve( [ 'item' ] ); + }, + } ); + + counter++; + + return response; + }; + + const result = await fetchAllMiddleware( originalOptions, next ); + + expect( result ).toEqual( [ 'item', 'item' ] ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/http-v1.js b/gb-src/packages/api-fetch/src/middlewares/test/http-v1.js new file mode 100644 index 0000000000000..6be47038ae83f --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/http-v1.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import httpV1Middleware from '../http-v1'; + +describe( 'HTTP v1 Middleware', () => { + it( 'should use a POST for a PUT requests', () => { + expect.hasAssertions(); + + const callback = ( options ) => { + expect( options.method ).toBe( 'POST' ); + expect( options.headers[ 'X-HTTP-Method-Override' ] ).toBe( 'PUT' ); + }; + + httpV1Middleware( { method: 'PUT', data: {} }, callback ); + } ); + + it( "shouldn't touch the options for GET requests", () => { + expect.hasAssertions(); + + const requestOptions = { method: 'GET', path: '/wp/v2/posts' }; + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + }; + + httpV1Middleware( requestOptions, callback ); + } ); + + it( "shouldn't touch the options for an undefined method", () => { + expect.hasAssertions(); + + const requestOptions = { path: '/wp/v2/posts' }; + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + }; + + httpV1Middleware( requestOptions, callback ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/namespace-endpoint.js b/gb-src/packages/api-fetch/src/middlewares/test/namespace-endpoint.js new file mode 100644 index 0000000000000..9e4b088f6089f --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/namespace-endpoint.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import namespaceEndpointMiddleware from '../namespace-endpoint'; + +describe( 'Namespace & Endpoint middleware', () => { + it( 'should concat the endpoint and namespace into a path property', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + namespace: '/wp/v2', + endpoint: '/posts', + }; + const callback = ( options ) => { + expect( options.path ).toBe( 'wp/v2/posts' ); + expect( options.namespace ).toBeUndefined(); + expect( options.endpoint ).toBeUndefined(); + }; + + namespaceEndpointMiddleware( requestOptions, callback ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/nonce.js b/gb-src/packages/api-fetch/src/middlewares/test/nonce.js new file mode 100644 index 0000000000000..3ce40bbd55f77 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/nonce.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import createNonceMiddleware from '../nonce'; + +describe( 'Nonce middleware', () => { + it( 'should add a nonce header to the request', () => { + expect.hasAssertions(); + + const nonce = 'nonce'; + const nonceMiddleware = createNonceMiddleware( nonce ); + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts', + }; + const callback = ( options ) => { + expect( options.headers[ 'X-WP-Nonce' ] ).toBe( nonce ); + }; + + nonceMiddleware( requestOptions, callback ); + } ); + + it( 'should not add a nonce header to requests with nonces', () => { + expect.hasAssertions(); + + const nonce = 'nonce'; + const nonceMiddleware = createNonceMiddleware( nonce ); + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts', + headers: { 'X-WP-Nonce': 'existing nonce' }, + }; + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + expect( options.headers[ 'X-WP-Nonce' ] ).toBe( 'existing nonce' ); + }; + + nonceMiddleware( requestOptions, callback ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/preloading.js b/gb-src/packages/api-fetch/src/middlewares/test/preloading.js new file mode 100644 index 0000000000000..e2d8a1f4d0c68 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/preloading.js @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import createPreloadingMiddleware from '../preloading'; + +describe( 'Preloading Middleware', () => { + it( 'should return the preloaded data if provided', () => { + const body = { + status: 'this is the preloaded response', + }; + const preloadedData = { + 'wp/v2/posts': { + body, + }, + }; + const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); + const requestOptions = { + method: 'GET', + path: 'wp/v2/posts', + }; + + const response = prelooadingMiddleware( requestOptions ); + return response.then( ( value ) => { + expect( value ).toEqual( body ); + } ); + } ); + + describe.each( [ + [ 'GET' ], + [ 'OPTIONS' ], + ] )( '%s', ( method ) => { + describe.each( [ + [ 'all empty', {} ], + [ 'method empty', { [ method ]: {} } ], + ] )( '%s', ( label, preloadedData ) => { + it( 'should move to the next middleware if no preloaded data', () => { + const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); + const requestOptions = { + method, + path: 'wp/v2/posts', + }; + + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + return true; + }; + + const ret = prelooadingMiddleware( requestOptions, callback ); + expect( ret ).toBe( true ); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/root-url.js b/gb-src/packages/api-fetch/src/middlewares/test/root-url.js new file mode 100644 index 0000000000000..3480cddc532b0 --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/root-url.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import createRootUrlMiddleware from '../root-url'; + +describe( 'Root URL middleware', () => { + it( 'should append the root URL', () => { + expect.hasAssertions(); + + const rootURL = 'http://wp.org/wp-admin/rest/'; + const rootURLMiddleware = createRootUrlMiddleware( rootURL ); + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts', + }; + const callback = ( options ) => { + expect( options.url ).toBe( 'http://wp.org/wp-admin/rest/wp/v2/posts' ); + }; + + rootURLMiddleware( requestOptions, callback ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/test/user-locale.js b/gb-src/packages/api-fetch/src/middlewares/test/user-locale.js new file mode 100644 index 0000000000000..03dc9136ee20e --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/test/user-locale.js @@ -0,0 +1,126 @@ +/** + * Internal dependencies + */ +import userLocaleMiddleware from '../user-locale'; + +describe( 'User locale middleware', () => { + it( 'should append the _locale parameter to the path', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts', + }; + + const callback = ( options ) => { + expect( options.path ).toBe( '/wp/v2/posts?_locale=user' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'should append the _locale parameter to path with existing query argument', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts?foo=bar', + }; + + const callback = ( options ) => { + expect( options.path ).toBe( '/wp/v2/posts?foo=bar&_locale=user' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'does not modify existing single _locale parameter in path', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts?_locale=foo', + }; + + const callback = ( options ) => { + expect( options.path ).toBe( '/wp/v2/posts?_locale=foo' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'does not modify existing _locale parameter in path', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + path: '/wp/v2/posts?foo=bar&_locale=foo', + }; + + const callback = ( options ) => { + expect( options.path ).toBe( '/wp/v2/posts?foo=bar&_locale=foo' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'should append the _locale parameter to the url', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + url: 'http://wp.org/wp-json/wp/v2/posts', + }; + + const callback = ( options ) => { + expect( options.url ).toBe( 'http://wp.org/wp-json/wp/v2/posts?_locale=user' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'should append the _locale parameter to url with existing query argument', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + url: 'http://wp.org/wp-json/wp/v2/posts?foo=bar', + }; + + const callback = ( options ) => { + expect( options.url ).toBe( 'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=user' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'does not modify existing single _locale parameter in url', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + url: 'http://wp.org/wp-json/wp/v2/posts?_locale=foo', + }; + + const callback = ( options ) => { + expect( options.url ).toBe( 'http://wp.org/wp-json/wp/v2/posts?_locale=foo' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); + + it( 'does not modify existing _locale parameter in url', () => { + expect.hasAssertions(); + + const requestOptions = { + method: 'GET', + url: 'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=foo', + }; + + const callback = ( options ) => { + expect( options.url ).toBe( 'http://wp.org/wp-json/wp/v2/posts?foo=bar&_locale=foo' ); + }; + + userLocaleMiddleware( requestOptions, callback ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/middlewares/user-locale.js b/gb-src/packages/api-fetch/src/middlewares/user-locale.js new file mode 100644 index 0000000000000..22fd232caf91b --- /dev/null +++ b/gb-src/packages/api-fetch/src/middlewares/user-locale.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { addQueryArgs, hasQueryArg } from '@wordpress/url'; + +function userLocaleMiddleware( options, next ) { + if ( typeof options.url === 'string' && ! hasQueryArg( options.url, '_locale' ) ) { + options.url = addQueryArgs( options.url, { _locale: 'user' } ); + } + + if ( typeof options.path === 'string' && ! hasQueryArg( options.path, '_locale' ) ) { + options.path = addQueryArgs( options.path, { _locale: 'user' } ); + } + + return next( options, next ); +} + +export default userLocaleMiddleware; diff --git a/gb-src/packages/api-fetch/src/test/index.js b/gb-src/packages/api-fetch/src/test/index.js new file mode 100644 index 0000000000000..1becd96d96576 --- /dev/null +++ b/gb-src/packages/api-fetch/src/test/index.js @@ -0,0 +1,220 @@ +/** + * Internal dependencies + */ +import apiFetch from '../'; + +/** + * Mock return value for a successful fetch JSON return value. + * + * @return {Promise} Mock return value. + */ +const DEFAULT_FETCH_MOCK_RETURN = Promise.resolve( { + status: 200, + json: () => Promise.resolve( {} ), +} ); + +describe( 'apiFetch', () => { + const originalFetch = window.fetch; + + beforeEach( () => { + window.fetch = jest.fn(); + } ); + + afterAll( () => { + window.fetch = originalFetch; + } ); + + it( 'should call the API properly', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 200, + json() { + return Promise.resolve( { message: 'ok' } ); + }, + } ) ); + + return apiFetch( { path: '/random' } ).then( ( body ) => { + expect( body ).toEqual( { message: 'ok' } ); + } ); + } ); + + it( 'should fetch with non-JSON body', () => { + window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN ); + + const body = 'FormData'; + + apiFetch( { + path: '/wp/v2/media', + method: 'POST', + body, + } ); + + expect( window.fetch ).toHaveBeenCalledWith( '/wp/v2/media?_locale=user', { + credentials: 'include', + headers: { + Accept: 'application/json, */*;q=0.1', + }, + method: 'POST', + body, + } ); + } ); + + it( 'should fetch with a JSON body', () => { + window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN ); + + apiFetch( { + path: '/wp/v2/posts', + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + data: {}, + } ); + + expect( window.fetch ).toHaveBeenCalledWith( '/wp/v2/posts?_locale=user', { + body: '{}', + credentials: 'include', + headers: { + Accept: 'application/json, */*;q=0.1', + 'Content-Type': 'application/json', + }, + method: 'POST', + } ); + } ); + + it( 'should respect developer-provided options', () => { + window.fetch.mockReturnValue( DEFAULT_FETCH_MOCK_RETURN ); + + apiFetch( { + path: '/wp/v2/posts', + method: 'POST', + data: {}, + credentials: 'omit', + } ); + + expect( window.fetch ).toHaveBeenCalledWith( '/wp/v2/posts?_locale=user', { + body: '{}', + credentials: 'omit', + headers: { + Accept: 'application/json, */*;q=0.1', + 'Content-Type': 'application/json', + }, + method: 'POST', + } ); + } ); + + it( 'should return the error message properly', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 400, + json() { + return Promise.resolve( { + code: 'bad_request', + message: 'Bad Request', + } ); + }, + } ) ); + + return apiFetch( { path: '/random' } ).catch( ( body ) => { + expect( body ).toEqual( { + code: 'bad_request', + message: 'Bad Request', + } ); + } ); + } ); + + it( 'should return invalid JSON error if no json response', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 200, + } ) ); + + return apiFetch( { path: '/random' } ).catch( ( body ) => { + expect( body ).toEqual( { + code: 'invalid_json', + message: 'The response is not a valid JSON response.', + } ); + } ); + } ); + + it( 'should return invalid JSON error if response is not valid', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 200, + json() { + return Promise.reject(); + }, + } ) ); + + return apiFetch( { path: '/random' } ).catch( ( body ) => { + expect( body ).toEqual( { + code: 'invalid_json', + message: 'The response is not a valid JSON response.', + } ); + } ); + } ); + + it( 'should return null if response has no content status code', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 204, + } ) ); + + return apiFetch( { path: '/random' } ).catch( ( body ) => { + expect( body ).toEqual( null ); + } ); + } ); + + it( 'should not try to parse the response', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 200, + } ) ); + + return apiFetch( { path: '/random', parse: false } ).then( ( response ) => { + expect( response ).toEqual( { + status: 200, + } ); + } ); + } ); + + it( 'should not try to parse the error', () => { + window.fetch.mockReturnValue( Promise.resolve( { + status: 400, + } ) ); + + return apiFetch( { path: '/random', parse: false } ).catch( ( response ) => { + expect( response ).toEqual( { + status: 400, + } ); + } ); + } ); + + it( 'should not use the default fetch handler when using a custom fetch handler', () => { + const customFetchHandler = jest.fn(); + + apiFetch.setFetchHandler( customFetchHandler ); + + apiFetch( { path: '/random' } ); + + expect( window.fetch ).not.toHaveBeenCalled(); + + expect( customFetchHandler ).toHaveBeenCalledWith( { + path: '/random?_locale=user', + } ); + } ); + + it( 'should run the last-registered user-defined middleware first', () => { + // This could potentially impact other tests in that a lingering + // middleware is left. For the purposes of this test, it is sufficient + // to ensure that the last-registered middleware receives the original + // options object. It also assumes that some built-in middleware would + // either mutate or clone the original options if the extra middleware + // had been pushed to the stack. + expect.assertions( 1 ); + + const expectedOptions = {}; + + apiFetch.use( ( actualOptions, next ) => { + expect( actualOptions ).toBe( expectedOptions ); + + return next( actualOptions ); + } ); + + apiFetch( expectedOptions ); + } ); +} ); diff --git a/gb-src/packages/api-fetch/src/utils/response.js b/gb-src/packages/api-fetch/src/utils/response.js new file mode 100644 index 0000000000000..bb7b79ce2f825 --- /dev/null +++ b/gb-src/packages/api-fetch/src/utils/response.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Parses the apiFetch response. + * + * @param {Response} response + * @param {boolean} shouldParseResponse + * + * @return {Promise} Parsed response + */ +const parseResponse = ( response, shouldParseResponse = true ) => { + if ( shouldParseResponse ) { + if ( response.status === 204 ) { + return null; + } + + return response.json ? response.json() : Promise.reject( response ); + } + + return response; +}; + +const parseJsonAndNormalizeError = ( response ) => { + const invalidJsonError = { + code: 'invalid_json', + message: __( 'The response is not a valid JSON response.' ), + }; + + if ( ! response || ! response.json ) { + throw invalidJsonError; + } + + return response.json() + .catch( () => { + throw invalidJsonError; + } ); +}; + +/** + * Parses the apiFetch response properly and normalize response errors. + * + * @param {Response} response + * @param {boolean} shouldParseResponse + * + * @return {Promise} Parsed response. + */ +export const parseResponseAndNormalizeError = ( response, shouldParseResponse = true ) => { + return Promise.resolve( parseResponse( response, shouldParseResponse ) ) + .catch( ( res ) => parseAndThrowError( res, shouldParseResponse ) ); +}; + +export function parseAndThrowError( response, shouldParseResponse = true ) { + if ( ! shouldParseResponse ) { + throw response; + } + + return parseJsonAndNormalizeError( response ) + .then( ( error ) => { + const unknownError = { + code: 'unknown_error', + message: __( 'An unknown error occurred.' ), + }; + + throw error || unknownError; + } ); +} + diff --git a/gb-src/packages/autop/.npmrc b/gb-src/packages/autop/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/autop/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/autop/CHANGELOG.md b/gb-src/packages/autop/CHANGELOG.md new file mode 100644 index 0000000000000..0f40e07f86165 --- /dev/null +++ b/gb-src/packages/autop/CHANGELOG.md @@ -0,0 +1,33 @@ +## 2.3.0 (2019-05-21) + +### Bug Fix + +- `removep` will correctly preserve multi-line paragraph tags where attributes are present. + +## 2.1.0 (2019-03-06) + +### Bug Fix + +- `autop` correctly matches whitespace preceding and following block-level elements. + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +## 1.1.0 (2018-07-12) + +### New Feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.6 (2018-05-08) + +### Polish + +- Documentation: Fix API method typo for `removep`. ([#120](https://github.com/WordPress/packages/pull/120)) diff --git a/gb-src/packages/autop/README.md b/gb-src/packages/autop/README.md new file mode 100644 index 0000000000000..882affe18946e --- /dev/null +++ b/gb-src/packages/autop/README.md @@ -0,0 +1,68 @@ +# Autop + +JavaScript port of WordPress's automatic paragraph function `autop` and the `removep` reverse behavior. + +## Installation + +Install the module + +```bash +npm install @wordpress/autop --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +### API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="autop" href="#autop">#</a> **autop** + +Replaces double line-breaks with paragraph elements. + +A group of regex replaces used to identify text formatted with newlines and +replace double line-breaks with HTML paragraph tags. The remaining line- +breaks after conversion become `<br />` tags, unless br is set to 'false'. + +_Usage_ + +```js +import { autop } from '@wordpress/autop'; +autop( 'my text' ); // "<p>my text</p>" +``` + +_Parameters_ + +- _text_ `string`: The text which has to be formatted. +- _br_ `boolean`: Optional. If set, will convert all remaining line- breaks after paragraphing. Default true. + +_Returns_ + +- `string`: Text which has been converted into paragraph tags. + +<a name="removep" href="#removep">#</a> **removep** + +Replaces `<p>` tags with two line breaks. "Opposite" of autop(). + +Replaces `<p>` tags with two line breaks except where the `<p>` has attributes. +Unifies whitespace. Indents `<li>`, `<dt>` and `<dd>` for better readability. + +_Usage_ + +```js +import { removep } from '@wordpress/autop'; +removep( '<p>my text</p>' ); // "my text" +``` + +_Parameters_ + +- _html_ `string`: The content from the editor. + +_Returns_ + +- `string`: The content with stripped paragraph tags. + + +<!-- END TOKEN(Autogenerated API docs) --> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/autop/package.json b/gb-src/packages/autop/package.json new file mode 100644 index 0000000000000..ef57d2a3a0516 --- /dev/null +++ b/gb-src/packages/autop/package.json @@ -0,0 +1,30 @@ +{ + "name": "@wordpress/autop", + "version": "2.5.1", + "description": "WordPress's automatic paragraph functions `autop` and `removep`.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "autop" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/autop/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/autop" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/autop/src/index.js b/gb-src/packages/autop/src/index.js new file mode 100644 index 0000000000000..1225d5c6637d8 --- /dev/null +++ b/gb-src/packages/autop/src/index.js @@ -0,0 +1,426 @@ +/** + * The regular expression for an HTML element. + * + * @type {string} + */ +const htmlSplitRegex = ( () => { + /* eslint-disable no-multi-spaces */ + const comments = + '!' + // Start of comment, after the <. + '(?:' + // Unroll the loop: Consume everything until --> is found. + '-(?!->)' + // Dash not followed by end of comment. + '[^\\-]*' + // Consume non-dashes. + ')*' + // Loop possessively. + '(?:-->)?'; // End of comment. If not found, match all input. + + const cdata = + '!\\[CDATA\\[' + // Start of comment, after the <. + '[^\\]]*' + // Consume non-]. + '(?:' + // Unroll the loop: Consume everything until ]]> is found. + '](?!]>)' + // One ] not followed by end of comment. + '[^\\]]*' + // Consume non-]. + ')*?' + // Loop possessively. + '(?:]]>)?'; // End of comment. If not found, match all input. + + const escaped = + '(?=' + // Is the element escaped? + '!--' + + '|' + + '!\\[CDATA\\[' + + ')' + + '((?=!-)' + // If yes, which type? + comments + + '|' + + cdata + + ')'; + + const regex = + '(' + // Capture the entire match. + '<' + // Find start of element. + '(' + // Conditional expression follows. + escaped + // Find end of escaped element. + '|' + // ... else ... + '[^>]*>?' + // Find end of normal element. + ')' + + ')'; + + return new RegExp( regex ); + /* eslint-enable no-multi-spaces */ +} )(); + +/** + * Separate HTML elements and comments from the text. + * + * @param {string} input The text which has to be formatted. + * @return {Array} The formatted text. + */ +function htmlSplit( input ) { + const parts = []; + let workingInput = input; + + let match; + while ( ( match = workingInput.match( htmlSplitRegex ) ) ) { + parts.push( workingInput.slice( 0, match.index ) ); + parts.push( match[ 0 ] ); + workingInput = workingInput.slice( match.index + match[ 0 ].length ); + } + + if ( workingInput.length ) { + parts.push( workingInput ); + } + + return parts; +} + +/** + * Replace characters or phrases within HTML elements only. + * + * @param {string} haystack The text which has to be formatted. + * @param {Object} replacePairs In the form {from: 'to', ...}. + * @return {string} The formatted text. + */ +function replaceInHtmlTags( haystack, replacePairs ) { + // Find all elements. + const textArr = htmlSplit( haystack ); + let changed = false; + + // Extract all needles. + const needles = Object.keys( replacePairs ); + + // Loop through delimiters (elements) only. + for ( let i = 1; i < textArr.length; i += 2 ) { + for ( let j = 0; j < needles.length; j++ ) { + const needle = needles[ j ]; + if ( -1 !== textArr[ i ].indexOf( needle ) ) { + textArr[ i ] = textArr[ i ].replace( new RegExp( needle, 'g' ), replacePairs[ needle ] ); + changed = true; + // After one strtr() break out of the foreach loop and look at next element. + break; + } + } + } + + if ( changed ) { + haystack = textArr.join( '' ); + } + + return haystack; +} + +/** + * Replaces double line-breaks with paragraph elements. + * + * A group of regex replaces used to identify text formatted with newlines and + * replace double line-breaks with HTML paragraph tags. The remaining line- + * breaks after conversion become `<br />` tags, unless br is set to 'false'. + * + * @param {string} text The text which has to be formatted. + * @param {boolean} br Optional. If set, will convert all remaining line- + * breaks after paragraphing. Default true. + * + * @example + *```js + * import { autop } from '@wordpress/autop'; + * autop( 'my text' ); // "<p>my text</p>" + * ``` + * + * @return {string} Text which has been converted into paragraph tags. + */ +export function autop( text, br = true ) { + const preTags = []; + + if ( text.trim() === '' ) { + return ''; + } + + // Just to make things a little easier, pad the end. + text = text + '\n'; + + /* + * Pre tags shouldn't be touched by autop. + * Replace pre tags with placeholders and bring them back after autop. + */ + if ( text.indexOf( '<pre' ) !== -1 ) { + const textParts = text.split( '</pre>' ); + const lastText = textParts.pop(); + text = ''; + + for ( let i = 0; i < textParts.length; i++ ) { + const textPart = textParts[ i ]; + const start = textPart.indexOf( '<pre' ); + + // Malformed html? + if ( start === -1 ) { + text += textPart; + continue; + } + + const name = '<pre wp-pre-tag-' + i + '></pre>'; + preTags.push( [ name, textPart.substr( start ) + '</pre>' ] ); + + text += textPart.substr( 0, start ) + name; + } + + text += lastText; + } + // Change multiple <br>s into two line breaks, which will turn into paragraphs. + text = text.replace( /<br\s*\/?>\s*<br\s*\/?>/g, '\n\n' ); + + const allBlocks = '(?:table|thead|tfoot|caption|col|colgroup|tbody|tr|td|th|div|dl|dd|dt|ul|ol|li|pre|form|map|area|blockquote|address|math|style|p|h[1-6]|hr|fieldset|legend|section|article|aside|hgroup|header|footer|nav|figure|figcaption|details|menu|summary)'; + + // Add a double line break above block-level opening tags. + text = text.replace( new RegExp( '(<' + allBlocks + '[\\s\/>])', 'g' ), '\n\n$1' ); + + // Add a double line break below block-level closing tags. + text = text.replace( new RegExp( '(<\/' + allBlocks + '>)', 'g' ), '$1\n\n' ); + + // Standardize newline characters to "\n". + text = text.replace( /\r\n|\r/g, '\n' ); + + // Find newlines in all elements and add placeholders. + text = replaceInHtmlTags( text, { '\n': ' <!-- wpnl --> ' } ); + + // Collapse line breaks before and after <option> elements so they don't get autop'd. + if ( text.indexOf( '<option' ) !== -1 ) { + text = text.replace( /\s*<option/g, '<option' ); + text = text.replace( /<\/option>\s*/g, '</option>' ); + } + + /* + * Collapse line breaks inside <object> elements, before <param> and <embed> elements + * so they don't get autop'd. + */ + if ( text.indexOf( '</object>' ) !== -1 ) { + text = text.replace( /(<object[^>]*>)\s*/g, '$1' ); + text = text.replace( /\s*<\/object>/g, '</object>' ); + text = text.replace( /\s*(<\/?(?:param|embed)[^>]*>)\s*/g, '$1' ); + } + + /* + * Collapse line breaks inside <audio> and <video> elements, + * before and after <source> and <track> elements. + */ + if ( text.indexOf( '<source' ) !== -1 || text.indexOf( '<track' ) !== -1 ) { + text = text.replace( /([<\[](?:audio|video)[^>\]]*[>\]])\s*/g, '$1' ); + text = text.replace( /\s*([<\[]\/(?:audio|video)[>\]])/g, '$1' ); + text = text.replace( /\s*(<(?:source|track)[^>]*>)\s*/g, '$1' ); + } + + // Collapse line breaks before and after <figcaption> elements. + if ( text.indexOf( '<figcaption' ) !== -1 ) { + text = text.replace( /\s*(<figcaption[^>]*>)/, '$1' ); + text = text.replace( /<\/figcaption>\s*/, '</figcaption>' ); + } + + // Remove more than two contiguous line breaks. + text = text.replace( /\n\n+/g, '\n\n' ); + + // Split up the contents into an array of strings, separated by double line breaks. + const texts = text.split( /\n\s*\n/ ).filter( Boolean ); + + // Reset text prior to rebuilding. + text = ''; + + // Rebuild the content as a string, wrapping every bit with a <p>. + texts.forEach( ( textPiece ) => { + text += '<p>' + textPiece.replace( /^\n*|\n*$/g, '' ) + '</p>\n'; + } ); + + // Under certain strange conditions it could create a P of entirely whitespace. + text = text.replace( /<p>\s*<\/p>/g, '' ); + + // Add a closing <p> inside <div>, <address>, or <form> tag if missing. + text = text.replace( /<p>([^<]+)<\/(div|address|form)>/g, '<p>$1</p></$2>' ); + + // If an opening or closing block element tag is wrapped in a <p>, unwrap it. + text = text.replace( new RegExp( '<p>\\s*(<\/?' + allBlocks + '[^>]*>)\\s*<\/p>', 'g' ), '$1' ); + + // In some cases <li> may get wrapped in <p>, fix them. + text = text.replace( /<p>(<li.+?)<\/p>/g, '$1' ); + + // If a <blockquote> is wrapped with a <p>, move it inside the <blockquote>. + text = text.replace( /<p><blockquote([^>]*)>/gi, '<blockquote$1><p>' ); + text = text.replace( /<\/blockquote><\/p>/g, '</p></blockquote>' ); + + // If an opening or closing block element tag is preceded by an opening <p> tag, remove it. + text = text.replace( new RegExp( '<p>\\s*(<\/?' + allBlocks + '[^>]*>)', 'g' ), '$1' ); + + // If an opening or closing block element tag is followed by a closing <p> tag, remove it. + text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\\s*<\/p>', 'g' ), '$1' ); + + // Optionally insert line breaks. + if ( br ) { + // Replace newlines that shouldn't be touched with a placeholder. + text = text.replace( /<(script|style).*?<\/\\1>/g, ( match ) => match[ 0 ].replace( /\n/g, '<WPPreserveNewline />' ) ); + + // Normalize <br> + text = text.replace( /<br>|<br\/>/g, '<br />' ); + + // Replace any new line characters that aren't preceded by a <br /> with a <br />. + text = text.replace( /(<br \/>)?\s*\n/g, ( a, b ) => b ? a : '<br />\n' ); + + // Replace newline placeholders with newlines. + text = text.replace( /<WPPreserveNewline \/>/g, '\n' ); + } + + // If a <br /> tag is after an opening or closing block tag, remove it. + text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\\s*<br \/>', 'g' ), '$1' ); + + // If a <br /> tag is before a subset of opening or closing block tags, remove it. + text = text.replace( /<br \/>(\s*<\/?(?:p|li|div|dl|dd|dt|th|pre|td|ul|ol)[^>]*>)/g, '$1' ); + text = text.replace( /\n<\/p>$/g, '</p>' ); + + // Replace placeholder <pre> tags with their original content. + preTags.forEach( ( preTag ) => { + const [ name, original ] = preTag; + text = text.replace( name, original ); + } ); + + // Restore newlines in all elements. + if ( -1 !== text.indexOf( '<!-- wpnl -->' ) ) { + text = text.replace( /\s?<!-- wpnl -->\s?/g, '\n' ); + } + + return text; +} + +/** + * Replaces `<p>` tags with two line breaks. "Opposite" of autop(). + * + * Replaces `<p>` tags with two line breaks except where the `<p>` has attributes. + * Unifies whitespace. Indents `<li>`, `<dt>` and `<dd>` for better readability. + * + * @param {string} html The content from the editor. + * + * @example + * ```js + * import { removep } from '@wordpress/autop'; + * removep( '<p>my text</p>' ); // "my text" + * ``` + * + * @return {string} The content with stripped paragraph tags. + */ +export function removep( html ) { + const blocklist = 'blockquote|ul|ol|li|dl|dt|dd|table|thead|tbody|tfoot|tr|th|td|h[1-6]|fieldset|figure'; + const blocklist1 = blocklist + '|div|p'; + const blocklist2 = blocklist + '|pre'; + const preserve = []; + let preserveLinebreaks = false; + let preserveBr = false; + + if ( ! html ) { + return ''; + } + + // Protect script and style tags. + if ( html.indexOf( '<script' ) !== -1 || html.indexOf( '<style' ) !== -1 ) { + html = html.replace( /<(script|style)[^>]*>[\s\S]*?<\/\1>/g, function( match ) { + preserve.push( match ); + return '<wp-preserve>'; + } ); + } + + // Protect pre tags. + if ( html.indexOf( '<pre' ) !== -1 ) { + preserveLinebreaks = true; + html = html.replace( /<pre[^>]*>[\s\S]+?<\/pre>/g, function( a ) { + a = a.replace( /<br ?\/?>(\r\n|\n)?/g, '<wp-line-break>' ); + a = a.replace( /<\/?p( [^>]*)?>(\r\n|\n)?/g, '<wp-line-break>' ); + return a.replace( /\r?\n/g, '<wp-line-break>' ); + } ); + } + + // Remove line breaks but keep <br> tags inside image captions. + if ( html.indexOf( '[caption' ) !== -1 ) { + preserveBr = true; + html = html.replace( /\[caption[\s\S]+?\[\/caption\]/g, function( a ) { + return a.replace( /<br([^>]*)>/g, '<wp-temp-br$1>' ).replace( /[\r\n\t]+/, '' ); + } ); + } + + // Normalize white space characters before and after block tags. + html = html.replace( new RegExp( '\\s*</(' + blocklist1 + ')>\\s*', 'g' ), '</$1>\n' ); + html = html.replace( new RegExp( '\\s*<((?:' + blocklist1 + ')(?: [^>]*)?)>', 'g' ), '\n<$1>' ); + + // Mark </p> if it has any attributes. + html = html.replace( /(<p [^>]+>[\s\S]*?)<\/p>/g, '$1</p#>' ); + + // Preserve the first <p> inside a <div>. + html = html.replace( /<div( [^>]*)?>\s*<p>/gi, '<div$1>\n\n' ); + + // Remove paragraph tags. + html = html.replace( /\s*<p>/gi, '' ); + html = html.replace( /\s*<\/p>\s*/gi, '\n\n' ); + + // Normalize white space chars and remove multiple line breaks. + html = html.replace( /\n[\s\u00a0]+\n/g, '\n\n' ); + + // Replace <br> tags with line breaks. + html = html.replace( /(\s*)<br ?\/?>\s*/gi, function( match, space ) { + if ( space && space.indexOf( '\n' ) !== -1 ) { + return '\n\n'; + } + + return '\n'; + } ); + + // Fix line breaks around <div>. + html = html.replace( /\s*<div/g, '\n<div' ); + html = html.replace( /<\/div>\s*/g, '</div>\n' ); + + // Fix line breaks around caption shortcodes. + html = html.replace( /\s*\[caption([^\[]+)\[\/caption\]\s*/gi, '\n\n[caption$1[/caption]\n\n' ); + html = html.replace( /caption\]\n\n+\[caption/g, 'caption]\n\n[caption' ); + + // Pad block elements tags with a line break. + html = html.replace( new RegExp( '\\s*<((?:' + blocklist2 + ')(?: [^>]*)?)\\s*>', 'g' ), '\n<$1>' ); + html = html.replace( new RegExp( '\\s*</(' + blocklist2 + ')>\\s*', 'g' ), '</$1>\n' ); + + // Indent <li>, <dt> and <dd> tags. + html = html.replace( /<((li|dt|dd)[^>]*)>/g, ' \t<$1>' ); + + // Fix line breaks around <select> and <option>. + if ( html.indexOf( '<option' ) !== -1 ) { + html = html.replace( /\s*<option/g, '\n<option' ); + html = html.replace( /\s*<\/select>/g, '\n</select>' ); + } + + // Pad <hr> with two line breaks. + if ( html.indexOf( '<hr' ) !== -1 ) { + html = html.replace( /\s*<hr( [^>]*)?>\s*/g, '\n\n<hr$1>\n\n' ); + } + + // Remove line breaks in <object> tags. + if ( html.indexOf( '<object' ) !== -1 ) { + html = html.replace( /<object[\s\S]+?<\/object>/g, function( a ) { + return a.replace( /[\r\n]+/g, '' ); + } ); + } + + // Unmark special paragraph closing tags. + html = html.replace( /<\/p#>/g, '</p>\n' ); + + // Pad remaining <p> tags whit a line break. + html = html.replace( /\s*(<p [^>]+>[\s\S]*?<\/p>)/g, '\n$1' ); + + // Trim. + html = html.replace( /^\s+/, '' ); + html = html.replace( /[\s\u00a0]+$/, '' ); + + if ( preserveLinebreaks ) { + html = html.replace( /<wp-line-break>/g, '\n' ); + } + + if ( preserveBr ) { + html = html.replace( /<wp-temp-br([^>]*)>/g, '<br$1>' ); + } + + // Restore preserved tags. + if ( preserve.length ) { + html = html.replace( /<wp-preserve>/g, function() { + return preserve.shift(); + } ); + } + + return html; +} diff --git a/gb-src/packages/autop/src/test/index.test.js b/gb-src/packages/autop/src/test/index.test.js new file mode 100644 index 0000000000000..8fc73f4e03965 --- /dev/null +++ b/gb-src/packages/autop/src/test/index.test.js @@ -0,0 +1,514 @@ +/** + * Internal dependencies + */ +import { + autop, + removep, +} from '../'; + +test( 'empty string', () => { + expect( autop( '' ) ).toBe( '' ); +} ); + +test( 'first post', () => { + const expected = `<p>Welcome to WordPress! This post contains important information. After you read it, you can make it private to hide it from visitors but still have the information handy for future reference.</p> +<p>First things first:</p> +<ul> +<li><a href="%1$s" title="Subscribe to the WordPress mailing list for Release Notifications">Subscribe to the WordPress mailing list for release notifications</a></li> +</ul> +<p>As a subscriber, you will receive an email every time an update is available (and only then). This will make it easier to keep your site up to date, and secure from evildoers.<br /> +When a new version is released, <a href="%2$s" title="If you are already logged in, this will take you directly to the Dashboard">log in to the Dashboard</a> and follow the instructions.<br /> +Upgrading is a couple of clicks!</p> +<p>Then you can start enjoying the WordPress experience:</p> +<ul> +<li>Edit your personal information at <a href="%3$s" title="Edit settings like your password, your display name and your contact information">Users › Your Profile</a></li> +<li>Start publishing at <a href="%4$s" title="Create a new post">Posts › Add New</a> and at <a href="%5$s" title="Create a new page">Pages › Add New</a></li> +<li>Browse and install plugins at <a href="%6$s" title="Browse and install plugins at the official WordPress repository directly from your Dashboard">Plugins › Add New</a></li> +<li>Browse and install themes at <a href="%7$s" title="Browse and install themes at the official WordPress repository directly from your Dashboard">Appearance › Add New Themes</a></li> +<li>Modify and prettify your website’s links at <a href="%8$s" title="For example, select a link structure like: http://example.com/1999/12/post-name">Settings › Permalinks</a></li> +<li>Import content from another system or WordPress site at <a href="%9$s" title="WordPress comes with importers for the most common publishing systems">Tools › Import</a></li> +<li>Find answers to your questions at the <a href="%10$s" title="The official WordPress documentation, maintained by the WordPress community">WordPress Codex</a></li> +</ul> +<p>To keep this post for reference, <a href="%11$s" title="Click to edit the content and settings of this post">click to edit it</a>, go to the Publish box and change its Visibility from Public to Private.</p> +<p>Thank you for selecting WordPress. We wish you happy publishing!</p> +<p>PS. Not yet subscribed for update notifications? <a href="%1$s" title="Subscribe to the WordPress mailing list for Release Notifications">Do it now!</a></p> +`; + const testData = `Welcome to WordPress! This post contains important information. After you read it, you can make it private to hide it from visitors but still have the information handy for future reference. + +First things first: +<ul> +<li><a href="%1$s" title="Subscribe to the WordPress mailing list for Release Notifications">Subscribe to the WordPress mailing list for release notifications</a></li> +</ul> +As a subscriber, you will receive an email every time an update is available (and only then). This will make it easier to keep your site up to date, and secure from evildoers. +When a new version is released, <a href="%2$s" title="If you are already logged in, this will take you directly to the Dashboard">log in to the Dashboard</a> and follow the instructions. +Upgrading is a couple of clicks! + +Then you can start enjoying the WordPress experience: +<ul> +<li>Edit your personal information at <a href="%3$s" title="Edit settings like your password, your display name and your contact information">Users › Your Profile</a></li> +<li>Start publishing at <a href="%4$s" title="Create a new post">Posts › Add New</a> and at <a href="%5$s" title="Create a new page">Pages › Add New</a></li> +<li>Browse and install plugins at <a href="%6$s" title="Browse and install plugins at the official WordPress repository directly from your Dashboard">Plugins › Add New</a></li> +<li>Browse and install themes at <a href="%7$s" title="Browse and install themes at the official WordPress repository directly from your Dashboard">Appearance › Add New Themes</a></li> +<li>Modify and prettify your website’s links at <a href="%8$s" title="For example, select a link structure like: http://example.com/1999/12/post-name">Settings › Permalinks</a></li> +<li>Import content from another system or WordPress site at <a href="%9$s" title="WordPress comes with importers for the most common publishing systems">Tools › Import</a></li> +<li>Find answers to your questions at the <a href="%10$s" title="The official WordPress documentation, maintained by the WordPress community">WordPress Codex</a></li> +</ul> +To keep this post for reference, <a href="%11$s" title="Click to edit the content and settings of this post">click to edit it</a>, go to the Publish box and change its Visibility from Public to Private. + +Thank you for selecting WordPress. We wish you happy publishing! + +PS. Not yet subscribed for update notifications? <a href="%1$s" title="Subscribe to the WordPress mailing list for Release Notifications">Do it now!</a> +`; + + expect( autop( testData ) ).toBe( expected ); +} ); + +test( 'skip pre elements', () => { + const code = `(function(){ + +done = 0; +});`; + + // Not wrapped in <p> tags + let str = `<pre>${ code }</pre>`; + expect( autop( str ).trim() ).toBe( str ); + + // Text before/after is wrapped in <p> tags + str = `Look at this code\n\n<pre>${ code }</pre>\n\nIsn't that cool?`; + + // Expected text after autop + let expected = `<p>Look at this code</p>\n<pre>${ code }</pre>\n<p>Isn't that cool?</p>`; + expect( autop( str ).trim() ).toBe( expected ); + + // Make sure HTML breaks are maintained if manually inserted + str = 'Look at this code\n\n<pre>Line1<br />Line2<br>Line3<br/>Line4\nActual Line 2\nActual Line 3</pre>\n\nCool, huh?'; + expected = '<p>Look at this code</p>\n<pre>Line1<br />Line2<br>Line3<br/>Line4\nActual Line 2\nActual Line 3</pre>\n<p>Cool, huh?</p>'; + expect( autop( str ).trim() ).toBe( expected ); +} ); + +test( 'skip input elements', () => { + const str = 'Username: <input type="text" id="username" name="username" /><br />Password: <input type="password" id="password1" name="password1" />'; + expect( autop( str ).trim() ).toBe( '<p>' + str + '</p>' ); +} ); + +test( 'source_track_elements', () => { + const content = `Paragraph one. + +<video class="wp-video-shortcode" id="video-0-1" width="640" height="360" preload="metadata" controls="controls"> + <source type="video/mp4" src="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4" /> + <!-- WebM/VP8 for Firefox4, Opera, and Chrome --> + <source type="video/webm" src="myvideo.webm" /> + <!-- Ogg/Vorbis for older Firefox and Opera versions --> + <source type="video/ogg" src="myvideo.ogv" /> + <!-- Optional: Add subtitles for each language --> + <track kind="subtitles" src="subtitles.srt" srclang="en" /> + <!-- Optional: Add chapters --> + <track kind="chapters" src="chapters.srt" srclang="en" /> + <a href="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4">http://domain.tld/wp-content/uploads/2013/12/xyz.mp4</a> +</video> + +Paragraph two.`; + + const content2 = `Paragraph one. + +<video class="wp-video-shortcode" id="video-0-1" width="640" height="360" preload="metadata" controls="controls"> + +<source type="video/mp4" src="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4" /> + +<!-- WebM/VP8 for Firefox4, Opera, and Chrome --> +<source type="video/webm" src="myvideo.webm" /> + +<!-- Ogg/Vorbis for older Firefox and Opera versions --> +<source type="video/ogg" src="myvideo.ogv" /> + +<!-- Optional: Add subtitles for each language --> +<track kind="subtitles" src="subtitles.srt" srclang="en" /> + +<!-- Optional: Add chapters --> +<track kind="chapters" src="chapters.srt" srclang="en" /> + +<a href="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4">http://domain.tld/wp-content/uploads/2013/12/xyz.mp4</a> + +</video> + +Paragraph two.`; + + const expected = '<p>Paragraph one.</p>\n' + // line breaks only after <p> + '<p><video class="wp-video-shortcode" id="video-0-1" width="640" height="360" preload="metadata" controls="controls">' + + '<source type="video/mp4" src="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4" />' + + '<!-- WebM/VP8 for Firefox4, Opera, and Chrome -->' + + '<source type="video/webm" src="myvideo.webm" />' + + '<!-- Ogg/Vorbis for older Firefox and Opera versions -->' + + '<source type="video/ogg" src="myvideo.ogv" />' + + '<!-- Optional: Add subtitles for each language -->' + + '<track kind="subtitles" src="subtitles.srt" srclang="en" />' + + '<!-- Optional: Add chapters -->' + + '<track kind="chapters" src="chapters.srt" srclang="en" />' + + '<a href="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4">' + + 'http://domain.tld/wp-content/uploads/2013/12/xyz.mp4</a></video></p>\n' + + '<p>Paragraph two.</p>'; + + // When running the content through autop() from wp_richedit_pre() + const shortcodeContent = `Paragraph one. + +[video width="720" height="480" mp4="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4"] +<!-- WebM/VP8 for Firefox4, Opera, and Chrome --> +<source type="video/webm" src="myvideo.webm" /> +<!-- Ogg/Vorbis for older Firefox and Opera versions --> +<source type="video/ogg" src="myvideo.ogv" /> +<!-- Optional: Add subtitles for each language --> +<track kind="subtitles" src="subtitles.srt" srclang="en" /> +<!-- Optional: Add chapters --> +<track kind="chapters" src="chapters.srt" srclang="en" /> +[/video] + +Paragraph two.`; + + const shortcodeExpected = '<p>Paragraph one.</p>\n' + // line breaks only after <p> + '<p>[video width="720" height="480" mp4="http://domain.tld/wp-content/uploads/2013/12/xyz.mp4"]' + + '<!-- WebM/VP8 for Firefox4, Opera, and Chrome --><source type="video/webm" src="myvideo.webm" />' + + '<!-- Ogg/Vorbis for older Firefox and Opera versions --><source type="video/ogg" src="myvideo.ogv" />' + + '<!-- Optional: Add subtitles for each language --><track kind="subtitles" src="subtitles.srt" srclang="en" />' + + '<!-- Optional: Add chapters --><track kind="chapters" src="chapters.srt" srclang="en" />' + + '[/video]</p>\n' + + '<p>Paragraph two.</p>'; + + expect( autop( content ).trim() ).toBe( expected ); + expect( autop( content2 ).trim() ).toBe( expected ); + expect( autop( shortcodeContent ).trim() ).toBe( shortcodeExpected ); +} ); + +test( 'param embed elements', () => { + const content1 = `Paragraph one. + +<object width="400" height="224" classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=6,0,40,0"> + <param name="src" value="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" /> + <param name="allowfullscreen" value="true" /> + <param name="allowscriptaccess" value="always" /> + <param name="overstretch" value="true" /> + <param name="flashvars" value="isDynamicSeeking=true" /> + + <embed width="400" height="224" type="application/x-shockwave-flash" src="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" wmode="direct" seamlesstabbing="true" allowfullscreen="true" overstretch="true" flashvars="isDynamicSeeking=true" /> +</object> + +Paragraph two.`; + + const expected1 = '<p>Paragraph one.</p>\n' + // line breaks only after <p> + '<p><object width="400" height="224" classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=6,0,40,0">' + + '<param name="src" value="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" />' + + '<param name="allowfullscreen" value="true" />' + + '<param name="allowscriptaccess" value="always" />' + + '<param name="overstretch" value="true" />' + + '<param name="flashvars" value="isDynamicSeeking=true" />' + + '<embed width="400" height="224" type="application/x-shockwave-flash" src="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" wmode="direct" seamlesstabbing="true" allowfullscreen="true" overstretch="true" flashvars="isDynamicSeeking=true" />' + + '</object></p>\n' + + '<p>Paragraph two.</p>'; + + const content2 = `Paragraph one. + +<div class="video-player" id="x-video-0"> +<object classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" width="640" height="360" id="video-0" standby="Standby text"> +<param name="movie" value="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" /> +<param name="quality" value="best" /> + +<param name="seamlesstabbing" value="true" /> +<param name="allowfullscreen" value="true" /> +<param name="allowscriptaccess" value="always" /> +<param name="overstretch" value="true" /> + +<!--[if !IE]--><object type="application/x-shockwave-flash" data="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" width="640" height="360" standby="Standby text"> +<param name="quality" value="best" /> + +<param name="seamlesstabbing" value="true" /> +<param name="allowfullscreen" value="true" /> +<param name="allowscriptaccess" value="always" /> +<param name="overstretch" value="true" /> +</object><!--<![endif]--> +</object></div> + +Paragraph two.`; + + const expected2 = '<p>Paragraph one.</p>\n' + // line breaks only after block tags + '<div class="video-player" id="x-video-0">\n' + + '<object classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" width="640" height="360" id="video-0" standby="Standby text">' + + '<param name="movie" value="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" />' + + '<param name="quality" value="best" />' + + '<param name="seamlesstabbing" value="true" />' + + '<param name="allowfullscreen" value="true" />' + + '<param name="allowscriptaccess" value="always" />' + + '<param name="overstretch" value="true" />' + + '<!--[if !IE]--><object type="application/x-shockwave-flash" data="http://domain.tld/wp-content/uploads/2013/12/xyz.swf" width="640" height="360" standby="Standby text">' + + '<param name="quality" value="best" />' + + '<param name="seamlesstabbing" value="true" />' + + '<param name="allowfullscreen" value="true" />' + + '<param name="allowscriptaccess" value="always" />' + + '<param name="overstretch" value="true" /></object><!--<![endif]-->' + + '</object></div>\n' + + '<p>Paragraph two.</p>'; + + expect( autop( content1 ).trim() ).toBe( expected1 ); + expect( autop( content2 ).trim() ).toBe( expected2 ); +} ); + +test( 'skip select option elements', () => { + const str = 'Country: <select id="state" name="state"><option value="1">Alabama</option><option value="2">Alaska</option><option value="3">Arizona</option><option value="4">Arkansas</option><option value="5">California</option></select>'; + + expect( autop( str ).trim() ).toBe( '<p>' + str + '</p>' ); +} ); + +test( 'that_autop_treats_block_level_elements_as_blocks', () => { + const blocks = [ + 'table', + 'thead', + 'tfoot', + 'caption', + 'col', + 'colgroup', + 'tbody', + 'tr', + 'td', + 'th', + 'div', + 'dl', + 'dd', + 'dt', + 'ul', + 'ol', + 'li', + 'pre', + 'form', + 'map', + 'area', + 'address', + 'math', + 'style', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'fieldset', + 'legend', + 'section', + 'article', + 'aside', + 'hgroup', + 'header', + 'footer', + 'nav', + 'figure', + 'details', + 'menu', + 'summary', + ]; + + // Check whitespace normalization. + let content = []; + + blocks.forEach( ( block ) => { + content.push( `<${ block }>foo</${ block }>` ); + } ); + + let expected = content.join( '\n' ); + let input = content.join( '\n\n' ); // WS difference + + expect( autop( input ).trim() ).toBe( expected ); + + input = content.join( '' ); // WS difference + + expect( autop( input ).trim() ).toBe( expected ); + + // Check whitespace addition. + content = []; + + blocks.forEach( ( block ) => { + content.push( `<${ block }/>` ); + } ); + + expected = content.join( '\n' ); + input = content.join( '' ); + + expect( autop( input ).trim() ).toBe( expected ); + + // Check whitespace addition with attributes. + content = []; + + blocks.forEach( ( block ) => { + content.push( `<${ block } attr='value'>foo</${ block }>` ); + } ); + + expected = content.join( '\n' ); + input = content.join( '' ); + + expect( autop( input ).trim() ).toBe( expected ); +} ); + +test( 'autop does not wrap blockquotes but does autop their contents', () => { + const content = '<blockquote>foo</blockquote>'; + const expected = '<blockquote><p>foo</p></blockquote>'; + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +test( 'that autop treats inline elements as inline', () => { + const inlines = [ + 'a', + 'em', + 'strong', + 'small', + 's', + 'cite', + 'q', + 'dfn', + 'abbr', + 'data', + 'time', + 'code', + 'var', + 'samp', + 'kbd', + 'sub', + 'sup', + 'i', + 'b', + 'u', + 'mark', + 'span', + 'del', + 'ins', + 'noscript', + 'select', + ]; + + let content = []; + let expected = []; + + inlines.forEach( ( inline ) => { + content.push( `<${ inline }>foo</${ inline }>` ); + expected.push( `<p><${ inline }>foo</${ inline }></p>` ); + } ); + + content = content.join( '\n\n' ); + expected = expected.join( '\n' ); + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +test( 'element sanity', () => { + [ + [ + 'Hello <a\nhref="world">', + '<p>Hello <a\nhref="world"></p>\n', + ], + [ + 'Hello <!-- a\nhref="world" -->', + '<p>Hello <!-- a\nhref="world" --></p>\n', + ], + [ + 'Hello <!-- <object>\n<param>\n<param>\n<embed>\n</embed>\n</object>\n -->', + '<p>Hello <!-- <object>\n<param>\n<param>\n<embed>\n</embed>\n</object>\n --></p>\n', + ], + [ + 'Hello <!-- <object>\n<param/>\n<param/>\n<embed>\n</embed>\n</object>\n -->', + '<p>Hello <!-- <object>\n<param/>\n<param/>\n<embed>\n</embed>\n</object>\n --></p>\n', + ], + /* Block elements inside comments will fail this test in all versions, it's not a regression. + [ + 'Hello <!-- <hr> a\nhref='world' -->', + '<p>Hello <!-- <hr> a\nhref='world' --></p>\n', + ], + [ + 'Hello <![CDATA[ <hr> a\nhttps://youtu.be/jgz0uSaOZbE\n ]]>', + '<p>Hello <![CDATA[ <hr> a\nhttps://youtu.be/jgz0uSaOZbE\n ]]></p>\n', + ], + */ + [ + 'Hello <![CDATA[ a\nhttps://youtu.be/jgz0uSaOZbE\n ]]>', + '<p>Hello <![CDATA[ a\nhttps://youtu.be/jgz0uSaOZbE\n ]]></p>\n', + ], + [ + 'Hello <![CDATA[ <!-- a\nhttps://youtu.be/jgz0uSaOZbE\n a\n9 ]]> -->', + '<p>Hello <![CDATA[ <!-- a\nhttps://youtu.be/jgz0uSaOZbE\n a\n9 ]]> --></p>\n', + ], + [ + 'Hello <![CDATA[ <!-- a\nhttps://youtu.be/jgz0uSaOZbE\n a\n9 --> a\n9 ]]>', + '<p>Hello <![CDATA[ <!-- a\nhttps://youtu.be/jgz0uSaOZbE\n a\n9 --> a\n9 ]]></p>\n', + ], + ].forEach( ( [ input, output ] ) => { + expect( autop( input ) ).toBe( output ); + } ); +} ); + +test( 'that autop skips line breaks after br', () => { + const content = ` +line 1<br> +line 2<br/> +line 3<br /> +line 4 +line 5 +`; + + const expected = `<p>line 1<br /> +line 2<br /> +line 3<br /> +line 4<br /> +line 5</p>`; + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +test( 'that autop adds a paragraph after multiple br', () => { + const content = ` +line 1<br> +<br/> +line 2<br/> +<br /> +`; + + const expected = `<p>line 1</p> +<p>line 2</p>`; + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +test( 'that text before blocks is peed', () => { + const content = 'a<div>b</div>'; + const expected = '<p>a</p>\n<div>b</div>'; + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +test( 'that autop doses not add extra closing p in figure', () => { + const content1 = '<figure><img src="example.jpg" /><figcaption>Caption</figcaption></figure>'; + const expected1 = content1; + + const content2 = `<figure> +<img src="example.jpg" /> +<figcaption>Caption</figcaption> +</figure>`; + + const expected2 = `<figure> +<img src="example.jpg" /><figcaption>Caption</figcaption></figure>`; + + expect( autop( content1 ).trim() ).toBe( expected1 ); + expect( autop( content2 ).trim() ).toBe( expected2 ); +} ); + +test( 'that autop correctly adds a start and end tag when followed by a div', () => { + const content = 'Testing autop with a div\n<div class="wp-some-class">content</div>'; + const expected = '<p>Testing autop with a div</p>\n<div class="wp-some-class">content</div>'; + + expect( autop( content ).trim() ).toBe( expected ); +} ); + +describe( 'removep', () => { + test( 'preserves paragraphs with attributes', () => { + const content = '<p style="text-align: center">\nHello World\n</p>'; + const expected = '<p style="text-align: center">\nHello World</p>'; + + expect( removep( content ) ).toBe( expected ); + } ); +} ); diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/.npmrc b/gb-src/packages/babel-plugin-import-jsx-pragma/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md b/gb-src/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md new file mode 100644 index 0000000000000..3bb5792f55c78 --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md @@ -0,0 +1,21 @@ +## 2.2.0 (2019-05-21) + +### New Feature + +- Add Fragment import handling ([#15120](https://github.com/WordPress/gutenberg/pull/15120)). + +## 2.0.0 (2019-03-06) + +### Breaking Change + +- Stop using Babel transpilation internally and set node 8 as a minimal version required ([#13540](https://github.com/WordPress/gutenberg/pull/13540)). + +### Enhancement + +- Plugin skips now adding import JSX pragma when the scope variable is defined for all JSX elements ([#13809](https://github.com/WordPress/gutenberg/pull/13809)). + +## 1.1.0 (2018-09-05) + +### New Feature + +- Plugin updated to work with the stable version of Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/README.md b/gb-src/packages/babel-plugin-import-jsx-pragma/README.md new file mode 100644 index 0000000000000..2459cea08be45 --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/README.md @@ -0,0 +1,85 @@ +# Babel Plugin Import JSX Pragma + +Babel transform plugin for automatically injecting an import to be used as the pragma for the [React JSX Transform plugin](http://babeljs.io/docs/en/babel-plugin-transform-react-jsx). + +[JSX](https://reactjs.org/docs/jsx-in-depth.html) is merely a syntactic sugar for a function call, typically to `React.createElement` when used with [React](https://reactjs.org/). As such, it requires that the function referenced by this transform be within the scope of the file where the JSX occurs. In a typical React project, this means React must be imported in any file where JSX exists. + +**Babel Plugin Import JSX Pragma** automates this process by introducing the necessary import automatically wherever JSX exists, allowing you to use JSX in your code without thinking to ensure the transformed function is within scope. It respects existing import statements, as well as scope variable declarations. + +## Installation + +Install the module to your project using [npm](https://www.npmjs.com/). + +```bash +npm install @wordpress/babel-plugin-import-jsx-pragma +``` + +## Usage + +Refer to the [Babel Plugins documentation](http://babeljs.io/docs/en/plugins) if you don't yet have experience working with Babel plugins. + +Include `@wordpress/babel-plugin-import-jsx-pragma` (and [@babel/transform-react-jsx](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx/)) as plugins in your Babel configuration. If you don't include both you will receive errors when encountering JSX tokens. + +```js +// .babelrc.js +module.exports = { + plugins: [ + '@wordpress/babel-plugin-import-jsx-pragma', + '@babel/transform-react-jsx', + ], +}; +``` + +_Note:_ `@wordpress/babel-plugin-import-jsx-pragma` is included in `@wordpress/babel-preset-default` (default preset for WordPress development) starting from `v4.0.0`. If you are using this preset, you shouldn't include this plugin in your Babel config. + +## Options + +As the `@babel/transform-react-jsx` plugin offers options to customize the `pragma` to which the transform references, there are equivalent options to assign for customizing the imports generated. + +For example, if you are using the `@wordpress/element` package, you may want to use the following configuration: + +```js +// .babelrc.js +module.exports = { + plugins: [ + [ '@wordpress/babel-plugin-import-jsx-pragma', { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ], + [ '@babel/transform-react-jsx', { + pragma: 'createElement', + pragmaFrag: 'Fragment', + } ], + ], +}; +``` + +### `scopeVariable` + +_Type:_ String + +Name of variable required to be in scope for use by the JSX pragma. For the default pragma of React.createElement, the React variable must be within scope. + +### `scopeVariableFrag` + +_Type:_ String + +Name of variable required to be in scope for `<></>` `Fragment` JSX. Named `<Fragment />` elements +expect Fragment to be in scope and will not add the import. + +### `source` + +_Type:_ String + +The module from which the scope variable is to be imported when missing. + +### `isDefault` + +_Type:_ Boolean + +Whether the scopeVariable is the default import of the source module. Note that this has no impact +on `scopeVariableFrag`. + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/index.js b/gb-src/packages/babel-plugin-import-jsx-pragma/index.js new file mode 100644 index 0000000000000..273df752611c1 --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/index.js @@ -0,0 +1,106 @@ +/** + * Default options for the plugin. + * + * @property {string} scopeVariable Name of variable required to be in scope + * for use by the JSX pragma. For the default + * pragma of React.createElement, the React + * variable must be within scope. + * @property {string} scopeVariableFrag Name of variable required to be in scope + * for use by the Fragment pragma. + * @property {string} source The module from which the scope variable + * is to be imported when missing. + * @property {boolean} isDefault Whether the scopeVariable is the default + * import of the source module. + */ +const DEFAULT_OPTIONS = { + scopeVariable: 'React', + scopeVariableFrag: null, + source: 'react', + isDefault: true, +}; + +/** + * Babel transform plugin for automatically injecting an import to be used as + * the pragma for the React JSX Transform plugin. + * + * @see http://babeljs.io/docs/en/babel-plugin-transform-react-jsx + * + * @param {Object} babel Babel instance. + * + * @return {Object} Babel transform plugin. + */ +module.exports = function( babel ) { + const { types: t } = babel; + + function getOptions( state ) { + if ( ! state._options ) { + state._options = Object.assign( {}, DEFAULT_OPTIONS, state.opts ); + } + + return state._options; + } + + return { + visitor: { + JSX( path, state ) { + if ( state.hasUndeclaredScopeVariable ) { + return; + } + + const { scopeVariable } = getOptions( state ); + state.hasUndeclaredScopeVariable = ! path.scope.hasBinding( scopeVariable ); + }, + JSXFragment( path, state ) { + if ( state.hasUndeclaredScopeVariableFrag ) { + return; + } + + const { scopeVariableFrag } = getOptions( state ); + if ( scopeVariableFrag === null ) { + return; + } + + state.hasUndeclaredScopeVariableFrag = ! path.scope.hasBinding( scopeVariableFrag ); + }, + Program: { + exit( path, state ) { + const { scopeVariable, scopeVariableFrag, source, isDefault } = getOptions( state ); + + let scopeVariableSpecifier; + let scopeVariableFragSpecifier; + + if ( state.hasUndeclaredScopeVariable ) { + if ( isDefault ) { + scopeVariableSpecifier = t.importDefaultSpecifier( t.identifier( scopeVariable ) ); + } else { + scopeVariableSpecifier = t.importSpecifier( + t.identifier( scopeVariable ), + t.identifier( scopeVariable ) + ); + } + } + + if ( state.hasUndeclaredScopeVariableFrag ) { + scopeVariableFragSpecifier = t.importSpecifier( + t.identifier( scopeVariableFrag ), + t.identifier( scopeVariableFrag ) + ); + } + + const importDeclarationSpecifiers = [ + scopeVariableSpecifier, + scopeVariableFragSpecifier, + ].filter( Boolean ); + if ( importDeclarationSpecifiers.length ) { + const importDeclaration = t.importDeclaration( + importDeclarationSpecifiers, + t.stringLiteral( source ) + ); + + path.unshiftContainer( 'body', importDeclaration ); + } + }, + }, + }, + }; +}; diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/package.json b/gb-src/packages/babel-plugin-import-jsx-pragma/package.json new file mode 100644 index 0000000000000..b744b8a3f0dd5 --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wordpress/babel-plugin-import-jsx-pragma", + "version": "2.3.0", + "description": "Babel transform plugin for automatically injecting an import to be used as the pragma for the React JSX Transform plugin.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "babel-plugin", + "jsx", + "pragma", + "react" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-plugin-import-jsx-pragma/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/babel-plugin-import-jsx-pragma" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "index.js" + ], + "main": "index.js", + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/babel-plugin-import-jsx-pragma/test/index.js b/gb-src/packages/babel-plugin-import-jsx-pragma/test/index.js new file mode 100644 index 0000000000000..c3e098476db3d --- /dev/null +++ b/gb-src/packages/babel-plugin-import-jsx-pragma/test/index.js @@ -0,0 +1,160 @@ +/** + * External dependencies + */ +import { transformSync } from '@babel/core'; + +/** + * Internal dependencies + */ +import plugin from '../'; + +describe( 'babel-plugin-import-jsx-pragma', () => { + it( 'does nothing if there is no jsx', () => { + const original = 'let foo;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( original ); + } ); + + it( 'does nothing if there scope variable already imported', () => { + const original = 'import React from "react";\nlet foo = <bar />;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( original ); + } ); + + it( 'does nothing if the scope variable is already defined', () => { + const original = 'const React = require("react");\n\nlet foo = <bar />;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( original ); + } ); + + it( 'adds import for scope variable', () => { + const original = 'let foo = <bar />;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( 'import React from "react";\n' + original ); + } ); + + it( 'adds import for scope variable for Fragments', () => { + const original = 'let foo = <></>;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( 'import React from "react";\nlet foo = <></>;' ); + } ); + + it( 'allows options customization', () => { + const original = 'let foo = <bar />;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( 'import { createElement } from "@wordpress/element";\n' + original ); + } ); + + it( 'adds import for scope variable even when defined inside the local scope', () => { + const original = + 'let foo = <bar />;\n\nfunction local() {\n const createElement = wp.element.createElement;\n}'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( 'import { createElement } from "@wordpress/element";\n' + original ); + } ); + + it( 'does nothing if the outer scope variable is already defined when using custom options', () => { + const original = + 'const {\n createElement,\n Fragment\n} = wp.element;\nlet foo = <><bar /></>;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( original ); + } ); + + it( 'adds only Fragment when required', () => { + const original = 'const {\n createElement\n} = wp.element;\nlet foo = <><bar /></>;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( + 'import { Fragment } from "@wordpress/element";\nconst {\n createElement\n} = wp.element;\nlet foo = <><bar /></>;' + ); + } ); + + it( 'adds only createElement when required', () => { + const original = 'const {\n Fragment\n} = wp.element;\nlet foo = <><bar /></>;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( + 'import { createElement } from "@wordpress/element";\nconst {\n Fragment\n} = wp.element;\nlet foo = <><bar /></>;' + ); + } ); + + it( 'does nothing if the inner scope variable is already defined when using custom options', () => { + const original = + '(function () {\n const {\n createElement\n } = wp.element;\n let foo = <bar />;\n})();'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( original ); + } ); + + it( 'adds Fragment as for <></>', () => { + const original = 'let foo = <><bar /><baz /></>;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( + 'import { createElement, Fragment } from "@wordpress/element";\nlet foo = <><bar /><baz /></>;' + ); + } ); + + it( 'does not add Fragment import for <Fragment />', () => { + const original = 'let foo = <Fragment><bar /><baz /></Fragment>;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + } ); + + expect( string ).toBe( + 'import { createElement } from "@wordpress/element";\nlet foo = <Fragment><bar /><baz /></Fragment>;' + ); + } ); +} ); + +function getTransformedCode( source, options = {} ) { + const { code } = transformSync( source, { + configFile: false, + plugins: [ [ plugin, options ], '@babel/plugin-syntax-jsx' ], + } ); + + return code; +} diff --git a/gb-src/packages/babel-plugin-makepot/.npmrc b/gb-src/packages/babel-plugin-makepot/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/babel-plugin-makepot/CHANGELOG.md b/gb-src/packages/babel-plugin-makepot/CHANGELOG.md new file mode 100644 index 0000000000000..1ddb1f2212294 --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/CHANGELOG.md @@ -0,0 +1,28 @@ +## 2.2.0 (2019-03-06) + +### Bug Fix + +- Fixed Babel plugin for POT file generation to properly handle plural numbers specified in the passed header. ([#13577](https://github.com/WordPress/gutenberg/pull/13577)) +- Fix extracted translator comments to be written as prefixed by `#.` ([#9440](https://github.com/WordPress/gutenberg/pull/9440)) + +## 2.1.0 (2018-09-05) + +### New Feature + +- Plugin updated to work with the stable version of Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). + +## 2.0.0 (2018-07-12) + +### Breaking Change + +- Updated code to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.1 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) diff --git a/gb-src/packages/babel-plugin-makepot/README.md b/gb-src/packages/babel-plugin-makepot/README.md new file mode 100644 index 0000000000000..6cade321228b3 --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/README.md @@ -0,0 +1,21 @@ +# Babel Plugin Makepot + +Babel plugin used to scan JavaScript files for use of localization functions. It then compiles these into a [gettext POT formatted](https://en.wikipedia.org/wiki/Gettext) file as a template for translation. By default the output file will be written to `gettext.pot` of the root project directory. This can be overridden using the `"output"` option of the plugin. + +```json +{ + "plugins": [ + [ "@wordpress/babel-plugin-makepot", { "output": "languages/myplugin.pot" } ], + ] +} +``` + +## Installation + +Install the module: + +```bash +npm install @wordpress/babel-plugin-makepot --save-dev +``` + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/babel-plugin-makepot/package.json b/gb-src/packages/babel-plugin-makepot/package.json new file mode 100644 index 0000000000000..8667074fff839 --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/package.json @@ -0,0 +1,42 @@ +{ + "name": "@wordpress/babel-plugin-makepot", + "version": "3.2.1", + "description": "WordPress Babel internationalization (i18n) plugin.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "i18n", + "babel", + "plugin" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-plugin-makepot/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/babel-plugin-makepot" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "build", + "build-module" + ], + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@babel/runtime": "^7.4.4", + "gettext-parser": "^1.3.1", + "lodash": "^4.17.15" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/babel-plugin-makepot/src/index.js b/gb-src/packages/babel-plugin-makepot/src/index.js new file mode 100644 index 0000000000000..ad0cde941aecb --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/src/index.js @@ -0,0 +1,345 @@ +/** + * Credits: + * + * babel-gettext-extractor + * https://github.com/getsentry/babel-gettext-extractor + * + * The MIT License (MIT) + * + * Copyright (c) 2015 jruchaud + * Copyright (c) 2015 Sentry + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * External dependencies + */ + +const { po } = require( 'gettext-parser' ); +const { pick, reduce, uniq, forEach, sortBy, isEqual, merge, isEmpty } = require( 'lodash' ); +const { relative, sep } = require( 'path' ); +const { writeFileSync } = require( 'fs' ); + +/** + * Default output headers if none specified in plugin options. + * + * @type {Object} + */ +const DEFAULT_HEADERS = { + 'content-type': 'text/plain; charset=UTF-8', + 'x-generator': 'babel-plugin-makepot', +}; + +/** + * Default functions to parse if none specified in plugin options. Each key is + * a CallExpression name (or member name) and the value an array corresponding + * to translation key argument position. + * + * @type {Object} + */ +const DEFAULT_FUNCTIONS = { + __: [ 'msgid' ], + _n: [ 'msgid', 'msgid_plural' ], + _x: [ 'msgid', 'msgctxt' ], + _nx: [ 'msgid', 'msgid_plural', null, 'msgctxt' ], +}; + +/** + * Default file output if none specified. + * + * @type {string} + */ +const DEFAULT_OUTPUT = 'gettext.pot'; + +/** + * Set of keys which are valid to be assigned into a translation object. + * + * @type {string[]} + */ +const VALID_TRANSLATION_KEYS = [ 'msgid', 'msgid_plural', 'msgctxt' ]; + +/** + * Regular expression matching translator comment value. + * + * @type {RegExp} + */ +const REGEXP_TRANSLATOR_COMMENT = /^\s*translators:\s*([\s\S]+)/im; + +/** + * Given an argument node (or recursed node), attempts to return a string + * represenation of that node's value. + * + * @param {Object} node AST node. + * + * @return {string} String value. + */ +function getNodeAsString( node ) { + switch ( node.type ) { + case 'BinaryExpression': + return ( + getNodeAsString( node.left ) + + getNodeAsString( node.right ) + ); + + case 'StringLiteral': + return node.value; + + default: + return ''; + } +} + +/** + * Returns the extracted comment for a given AST traversal path if one exists. + * + * @param {Object} path Traversal path. + * @param {number} _originalNodeLine Private: In recursion, line number of + * the original node passed. + * + * @return {?string} Extracted comment. + */ +function getExtractedComment( path, _originalNodeLine ) { + const { node, parent, parentPath } = path; + + // Assign original node line so we can keep track in recursion whether a + // matched comment or parent occurs on the same or previous line + if ( ! _originalNodeLine ) { + _originalNodeLine = node.loc.start.line; + } + + let comment; + forEach( node.leadingComments, ( commentNode ) => { + const { line } = commentNode.loc.end; + if ( line < _originalNodeLine - 1 || line > _originalNodeLine ) { + return; + } + + const match = commentNode.value.match( REGEXP_TRANSLATOR_COMMENT ); + if ( match ) { + // Extract text from matched translator prefix + comment = match[ 1 ].split( '\n' ).map( ( text ) => text.trim() ).join( ' ' ); + + // False return indicates to Lodash to break iteration + return false; + } + } ); + + if ( comment ) { + return comment; + } + + if ( ! parent || ! parent.loc || ! parentPath ) { + return; + } + + // Only recurse as long as parent node is on the same or previous line + const { line } = parent.loc.start; + if ( line >= _originalNodeLine - 1 && line <= _originalNodeLine ) { + return getExtractedComment( parentPath, _originalNodeLine ); + } +} + +/** + * Returns true if the specified key of a function is valid for assignment in + * the translation object. + * + * @param {string} key Key to test. + * + * @return {boolean} Whether key is valid for assignment. + */ +function isValidTranslationKey( key ) { + return -1 !== VALID_TRANSLATION_KEYS.indexOf( key ); +} + +/** + * Given two translation objects, returns true if valid translation keys match, + * or false otherwise. + * + * @param {Object} a First translation object. + * @param {Object} b Second translation object. + * + * @return {boolean} Whether valid translation keys match. + */ +function isSameTranslation( a, b ) { + return isEqual( + pick( a, VALID_TRANSLATION_KEYS ), + pick( b, VALID_TRANSLATION_KEYS ) + ); +} + +module.exports = function() { + const strings = {}; + let nplurals = 2, + baseData; + + return { + visitor: { + CallExpression( path, state ) { + const { callee } = path.node; + + // Determine function name by direct invocation or property name + let name; + if ( 'MemberExpression' === callee.type ) { + name = callee.property.name; + } else { + name = callee.name; + } + + // Skip unhandled functions + const functionKeys = ( state.opts.functions || DEFAULT_FUNCTIONS )[ name ]; + if ( ! functionKeys ) { + return; + } + + // Assign translation keys by argument position + const translation = path.node.arguments.reduce( ( memo, arg, i ) => { + const key = functionKeys[ i ]; + if ( isValidTranslationKey( key ) ) { + memo[ key ] = getNodeAsString( arg ); + } + + return memo; + }, {} ); + + // Can only assign translation with usable msgid + if ( ! translation.msgid ) { + return; + } + + // At this point we assume we'll save data, so initialize if + // we haven't already + if ( ! baseData ) { + baseData = { + charset: 'utf-8', + headers: state.opts.headers || DEFAULT_HEADERS, + translations: { + '': { + '': { + msgid: '', + msgstr: [], + }, + }, + }, + }; + + for ( const key in baseData.headers ) { + baseData.translations[ '' ][ '' ].msgstr.push( `${ key }: ${ baseData.headers[ key ] };\n` ); + } + + // Attempt to exract nplurals from header + const pluralsMatch = ( baseData.headers[ 'plural-forms' ] || '' ).match( /nplurals\s*=\s*(\d+);/ ); + if ( pluralsMatch ) { + nplurals = parseInt( pluralsMatch[ 1 ], 10 ); + } + } + + // Create empty msgstr or array of empty msgstr by nplurals + if ( translation.msgid_plural ) { + translation.msgstr = Array.from( Array( nplurals ) ).map( () => '' ); + } else { + translation.msgstr = ''; + } + + // Assign file reference comment, ensuring consistent pathname + // reference between Win32 and POSIX + const { filename } = this.file.opts; + const pathname = relative( '.', filename ).split( sep ).join( '/' ); + translation.comments = { + reference: pathname + ':' + path.node.loc.start.line, + }; + + // If exists, also assign translator comment + const translator = getExtractedComment( path ); + if ( translator ) { + translation.comments.extracted = translator; + } + + // Create context grouping for translation if not yet exists + const { msgctxt = '', msgid } = translation; + if ( ! strings[ filename ].hasOwnProperty( msgctxt ) ) { + strings[ filename ][ msgctxt ] = {}; + } + + strings[ filename ][ msgctxt ][ msgid ] = translation; + }, + Program: { + enter() { + strings[ this.file.opts.filename ] = {}; + }, + exit( path, state ) { + const { filename } = this.file.opts; + if ( isEmpty( strings[ filename ] ) ) { + delete strings[ filename ]; + return; + } + + // Sort translations by filename for deterministic output + const files = Object.keys( strings ).sort(); + + // Combine translations from each file grouped by context + const translations = reduce( files, ( memo, file ) => { + for ( const context in strings[ file ] ) { + // Within the same file, sort translations by line + const sortedTranslations = sortBy( + strings[ file ][ context ], + 'comments.reference' + ); + + forEach( sortedTranslations, ( translation ) => { + const { msgctxt = '', msgid } = translation; + if ( ! memo.hasOwnProperty( msgctxt ) ) { + memo[ msgctxt ] = {}; + } + + // Merge references if translation already exists + if ( isSameTranslation( translation, memo[ msgctxt ][ msgid ] ) ) { + translation.comments.reference = uniq( [ + memo[ msgctxt ][ msgid ].comments.reference, + translation.comments.reference, + ].join( '\n' ).split( '\n' ) ).join( '\n' ); + } + + memo[ msgctxt ][ msgid ] = translation; + } ); + } + + return memo; + }, {} ); + + // Merge translations from individual files into headers + const data = merge( {}, baseData, { translations } ); + + // Ideally we could wait until Babel has finished parsing + // all files or at least asynchronously write, but the + // Babel loader doesn't expose these entry points and async + // write may hit file lock (need queue). + const compiled = po.compile( data ); + writeFileSync( state.opts.output || DEFAULT_OUTPUT, compiled ); + this.hasPendingWrite = false; + }, + }, + }, + }; +}; + +module.exports.getNodeAsString = getNodeAsString; +module.exports.getExtractedComment = getExtractedComment; +module.exports.isValidTranslationKey = isValidTranslationKey; +module.exports.isSameTranslation = isSameTranslation; diff --git a/gb-src/packages/babel-plugin-makepot/test/index.js b/gb-src/packages/babel-plugin-makepot/test/index.js new file mode 100644 index 0000000000000..52ae1dcc5b913 --- /dev/null +++ b/gb-src/packages/babel-plugin-makepot/test/index.js @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { transformSync } from '@babel/core'; +import traverse from '@babel/traverse'; + +/** + * Internal dependencies + */ +import babelPlugin from '../src'; + +describe( 'babel-plugin', () => { + const { + getNodeAsString, + getExtractedComment, + isValidTranslationKey, + isSameTranslation, + } = babelPlugin; + + describe( '.isValidTranslationKey()', () => { + it( 'should return false if not one of valid keys', () => { + expect( isValidTranslationKey( 'foo' ) ).toBe( false ); + } ); + + it( 'should return true if one of valid keys', () => { + expect( isValidTranslationKey( 'msgid' ) ).toBe( true ); + } ); + } ); + + describe( '.isSameTranslation()', () => { + it( 'should return false if any translation keys differ', () => { + const a = { msgid: 'foo' }; + const b = { msgid: 'bar' }; + + expect( isSameTranslation( a, b ) ).toBe( false ); + } ); + + it( 'should return true if all translation keys the same', () => { + const a = { msgid: 'foo', comments: { reference: 'a' } }; + const b = { msgid: 'foo', comments: { reference: 'b' } }; + + expect( isSameTranslation( a, b ) ).toBe( true ); + } ); + } ); + + describe( '.getExtractedComment()', () => { + function getCommentFromString( string ) { + let comment; + traverse( transformSync( string, { ast: true } ).ast, { + CallExpression( path ) { + comment = getExtractedComment( path ); + }, + } ); + + return comment; + } + + it( 'should not return translator comment on same line but after call expression', () => { + const comment = getCommentFromString( "__( 'Hello world' ); // translators: Greeting" ); + + expect( comment ).toBeUndefined(); + } ); + + it( 'should return translator comment on leading comments', () => { + const comment = getCommentFromString( "// translators: Greeting\n__( 'Hello world' );" ); + + expect( comment ).toBe( 'Greeting' ); + } ); + + it( 'should be case insensitive to translator prefix', () => { + const comment = getCommentFromString( "// TrANslAtORs: Greeting\n__( 'Hello world' );" ); + + expect( comment ).toBe( 'Greeting' ); + } ); + + it( 'should traverse up parents until it encounters comment', () => { + const comment = getCommentFromString( "// translators: Greeting\nconst string = __( 'Hello world' );" ); + + expect( comment ).toBe( 'Greeting' ); + } ); + + it( 'should not consider comment if it does not end on same or previous line', () => { + const comment = getCommentFromString( "// translators: Greeting\n\n__( 'Hello world' );" ); + + expect( comment ).toBeUndefined(); + } ); + + it( 'should use multi-line comment starting many lines previous', () => { + const comment = getCommentFromString( "/* translators: Long comment\nspanning multiple \nlines */\nconst string = __( 'Hello world' );" ); + + expect( comment ).toBe( 'Long comment spanning multiple lines' ); + } ); + } ); + + describe( '.getNodeAsString()', () => { + function getNodeAsStringFromArgument( source ) { + let string; + traverse( transformSync( source, { ast: true } ).ast, { + CallExpression( path ) { + string = getNodeAsString( path.node.arguments[ 0 ] ); + }, + } ); + + return string; + } + + it( 'should returns an empty string by default', () => { + const string = getNodeAsStringFromArgument( '__( {} );' ); + + expect( string ).toBe( '' ); + } ); + + it( 'should return a string value', () => { + const string = getNodeAsStringFromArgument( '__( "hello" );' ); + + expect( string ).toBe( 'hello' ); + } ); + + it( 'should be a concatenated binary expression string value', () => { + const string = getNodeAsStringFromArgument( '__( "hello" + " world" );' ); + + expect( string ).toBe( 'hello world' ); + } ); + } ); +} ); diff --git a/gb-src/packages/babel-preset-default/.npmrc b/gb-src/packages/babel-preset-default/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/babel-preset-default/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/babel-preset-default/CHANGELOG.md b/gb-src/packages/babel-preset-default/CHANGELOG.md new file mode 100644 index 0000000000000..c979ee44f9b69 --- /dev/null +++ b/gb-src/packages/babel-preset-default/CHANGELOG.md @@ -0,0 +1,64 @@ +## 4.5.0 (2019-08-29) + +### Bug Fixes + +- Added missing `@wordpress/element` dependency which is used internally. + +## 4.4.0 (2019-08-05) + +### Bug Fixes + +- Configure Babel to target your current version of Node as described in [Jest docs](https://jestjs.io/docs/en/getting-started#using-babel). +- Added missing [core-js](https://www.npmjs.com/package/core-js) dependency ([#16259](https://github.com/WordPress/gutenberg/pull/16259)). + +## 4.2.0 (2019-05-21) + +### New Features + +- Handle `<></>` JSX Fragments with `@wordpress/element` `Fragment` ([#15120](https://github.com/WordPress/gutenberg/pull/15120)). +- The bundled `@babel/core` dependency has been updated from requiring `^7.2.2` to requiring `^7.4.4`. Babel preset is now using `core-js@3` instead of `core-js@2` (see [Migration Guide](https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2)). + +## 4.0.0 (2019-03-06) + +### Breaking Changes + +- Removed `babel-core` dependency acting as Babel 7 bridge ([#13922](https://github.com/WordPress/gutenberg/pull/13922). Ensure all references to `babel-core` are replaced with `@babel/core` . +- Preset updated to include `@wordpress/babel-plugin-import-jsx-pragma` plugin integration ([#13540](https://github.com/WordPress/gutenberg/pull/13540)). It should no longer be explicitly included in your Babel config. + +### Bug Fix + +- The runtime transform no longer disables [the `regenerator` option](https://babeljs.io/docs/en/babel-plugin-transform-runtime#regenerator). This should resolve issues where a file generated using the preset would assume the presence of a `regeneratorRuntime` object in the global scope. While this is not considered a breaking change, you may be mindful to consider that with transformed output now explicitly importing the runtime regenerator, bundle sizes may increase if you do not otherwise mitigate the additional import by either (a) overriding the option in your own Babel configuration extensions or (b) redefining the resolved value of `@babel/runtime/regenerator` using a feature like [Webpack's `externals` option](https://webpack.js.org/configuration/externals/). + +## 3.0.0 (2018-09-30) + +### Breaking Change + +- The configured `@babel/preset-env` preset will no longer pass `useBuiltIns: 'usage'` as an option. It is therefore expected that a polyfill serve in its place, if necessary. + +## 2.1.0 (2018-09-05) + +### New Feature + +- Plugin updated to work with the stable version of Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). + +## 2.0.0 (2018-07-12) + +### Breaking Change + +- Updated code to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.3.0 (2018-05-22) + +### New Feature + +- Added support for async generator functions ([#126](https://github.com/WordPress/packages/pull/126)) + +## 1.2.1 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) diff --git a/gb-src/packages/babel-preset-default/README.md b/gb-src/packages/babel-preset-default/README.md new file mode 100644 index 0000000000000..a83b18ae52d51 --- /dev/null +++ b/gb-src/packages/babel-preset-default/README.md @@ -0,0 +1,37 @@ +# Babel Preset Default + +Default [Babel](https://babeljs.io/) preset for WordPress development. + +## Installation + +Install the module + +```bash +npm install @wordpress/babel-preset-default --save-dev +``` + +### Usage + +#### Via .babelrc (Recommended) + +```json +{ + "presets": [ "@wordpress/default" ] +} +``` + +#### Via CLI + +```bash +babel script.js --presets @wordpress/default +``` + +#### Via Node API + +```js +require( '@babel/core' ).transform( 'code', { + presets: [ '@wordpress/default' ] +} ); +``` + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/babel-preset-default/index.js b/gb-src/packages/babel-preset-default/index.js new file mode 100644 index 0000000000000..a1343706b03b8 --- /dev/null +++ b/gb-src/packages/babel-preset-default/index.js @@ -0,0 +1,76 @@ +module.exports = function( api ) { + let wpBuildOpts = {}; + const isWPBuild = ( name ) => [ 'WP_BUILD_MAIN', 'WP_BUILD_MODULE' ].some( + ( buildName ) => name === buildName + ); + + const isTestEnv = api.env() === 'test'; + + api.caller( ( caller ) => { + if ( caller && isWPBuild( caller.name ) ) { + wpBuildOpts = { ...caller }; + return caller.name; + } + return undefined; + } ); + + const getPresetEnv = () => { + const opts = {}; + + if ( isTestEnv ) { + opts.targets = { + node: 'current', + }; + } else { + opts.modules = false; + opts.targets = { + browsers: require( '@wordpress/browserslist-config' ), + }; + } + + if ( isWPBuild( wpBuildOpts.name ) ) { + opts.modules = wpBuildOpts.modules; + } + + return [ require.resolve( '@babel/preset-env' ), opts ]; + }; + + const maybeGetPluginTransformRuntime = () => { + if ( isTestEnv ) { + return undefined; + } + + const opts = { + helpers: true, + useESModules: false, + }; + + if ( wpBuildOpts.name === 'WP_BUILD_MODULE' ) { + opts.useESModules = wpBuildOpts.useESModules; + } + + return [ require.resolve( '@babel/plugin-transform-runtime' ), opts ]; + }; + + return { + presets: [ getPresetEnv() ], + plugins: [ + require.resolve( '@babel/plugin-proposal-object-rest-spread' ), + [ + require.resolve( '@wordpress/babel-plugin-import-jsx-pragma' ), + { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + }, + ], + [ require.resolve( '@babel/plugin-transform-react-jsx' ), { + pragma: 'createElement', + pragmaFrag: 'Fragment', + } ], + require.resolve( '@babel/plugin-proposal-async-generator-functions' ), + maybeGetPluginTransformRuntime(), + ].filter( Boolean ), + }; +}; diff --git a/gb-src/packages/babel-preset-default/package.json b/gb-src/packages/babel-preset-default/package.json new file mode 100644 index 0000000000000..c02981fc1316c --- /dev/null +++ b/gb-src/packages/babel-preset-default/package.json @@ -0,0 +1,45 @@ +{ + "name": "@wordpress/babel-preset-default", + "version": "4.6.2", + "description": "Default Babel preset for WordPress development.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "babel", + "preset", + "default" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-preset-default/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/babel-preset-default" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "index.js" + ], + "main": "index.js", + "dependencies": { + "@babel/core": "^7.4.5", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.4.4", + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/plugin-transform-runtime": "^7.4.4", + "@babel/preset-env": "^7.4.5", + "@babel/runtime": "^7.4.5", + "@wordpress/babel-plugin-import-jsx-pragma": "file:../babel-plugin-import-jsx-pragma", + "@wordpress/browserslist-config": "file:../browserslist-config", + "@wordpress/element": "file:../element", + "core-js": "^3.1.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/babel-preset-default/test/__snapshots__/index.js.snap b/gb-src/packages/babel-preset-default/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..605c23a784b40 --- /dev/null +++ b/gb-src/packages/babel-preset-default/test/__snapshots__/index.js.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Babel preset default transpilation works properly 1`] = ` +"import _asyncToGenerator from \\"@babel/runtime/helpers/asyncToGenerator\\"; +import _regeneratorRuntime from \\"@babel/runtime/regenerator\\"; +import _awaitAsyncGenerator from \\"@babel/runtime/helpers/awaitAsyncGenerator\\"; +import _wrapAsyncGenerator from \\"@babel/runtime/helpers/wrapAsyncGenerator\\"; +describe('Babel preset default', function () { + function foo() { + return _foo.apply(this, arguments); + } + + function _foo() { + _foo = _wrapAsyncGenerator( + /*#__PURE__*/ + _regeneratorRuntime.mark(function _callee() { + return _regeneratorRuntime.wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + _context.next = 2; + return _awaitAsyncGenerator(1); + + case 2: + _context.next = 4; + return 2; + + case 4: + case \\"end\\": + return _context.stop(); + } + } + }, _callee); + })); + return _foo.apply(this, arguments); + } + + test('support for async generator functions', + /*#__PURE__*/ + _asyncToGenerator( + /*#__PURE__*/ + _regeneratorRuntime.mark(function _callee2() { + var generator; + return _regeneratorRuntime.wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + generator = foo(); + _context2.t0 = expect; + _context2.next = 4; + return generator.next(); + + case 4: + _context2.t1 = _context2.sent; + _context2.t2 = { + done: false, + value: 2 + }; + (0, _context2.t0)(_context2.t1).toEqual(_context2.t2); + + case 7: + case \\"end\\": + return _context2.stop(); + } + } + }, _callee2); + }))); +});" +`; diff --git a/gb-src/packages/babel-preset-default/test/fixtures/input.js b/gb-src/packages/babel-preset-default/test/fixtures/input.js new file mode 100644 index 0000000000000..b7cec35c7f465 --- /dev/null +++ b/gb-src/packages/babel-preset-default/test/fixtures/input.js @@ -0,0 +1,15 @@ +describe( 'Babel preset default', () => { + async function* foo() { + await 1; + yield 2; + } + + test( 'support for async generator functions', async () => { + const generator = foo(); + + expect( await generator.next() ).toEqual( { + done: false, + value: 2, + } ); + } ); +} ); diff --git a/gb-src/packages/babel-preset-default/test/index.js b/gb-src/packages/babel-preset-default/test/index.js new file mode 100644 index 0000000000000..0be3197b9541c --- /dev/null +++ b/gb-src/packages/babel-preset-default/test/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import path from 'path'; +import { readFileSync } from 'fs'; +import { transform } from '@babel/core'; + +/** + * Internal dependencies + */ +import babelPresetDefault from '../'; + +describe( 'Babel preset default', () => { + test( 'transpilation works properly', () => { + const input = readFileSync( path.join( __dirname, '/fixtures/input.js' ) ); + + const output = transform( input, { + configFile: false, + envName: 'production', + presets: [ babelPresetDefault ], + } ); + + expect( output.code ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/blob/.npmrc b/gb-src/packages/blob/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/blob/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/blob/CHANGELOG.md b/gb-src/packages/blob/CHANGELOG.md new file mode 100644 index 0000000000000..1288967c3cfdb --- /dev/null +++ b/gb-src/packages/blob/CHANGELOG.md @@ -0,0 +1,13 @@ +## 2.1.0 (2018-10-19) + +### New Features + +- Added a new `isBlobURL` function. + +## 2.0.3 (2018-10-18) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/blob/README.md b/gb-src/packages/blob/README.md new file mode 100644 index 0000000000000..dfb1971f423f4 --- /dev/null +++ b/gb-src/packages/blob/README.md @@ -0,0 +1,66 @@ +# Blob + +Blob utilities for WordPress. + +## Installation + +Install the module + +```bash +npm install @wordpress/blob --save +``` + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="createBlobURL" href="#createBlobURL">#</a> **createBlobURL** + +Create a blob URL from a file. + +_Parameters_ + +- _file_ `File`: The file to create a blob URL for. + +_Returns_ + +- `string`: The blob URL. + +<a name="getBlobByURL" href="#getBlobByURL">#</a> **getBlobByURL** + +Retrieve a file based on a blob URL. The file must have been created by +`createBlobURL` and not removed by `revokeBlobURL`, otherwise it will return +`undefined`. + +_Parameters_ + +- _url_ `string`: The blob URL. + +_Returns_ + +- `?File`: The file for the blob URL. + +<a name="isBlobURL" href="#isBlobURL">#</a> **isBlobURL** + +Check whether a url is a blob url. + +_Parameters_ + +- _url_ `string`: The URL. + +_Returns_ + +- `boolean`: Is the url a blob url? + +<a name="revokeBlobURL" href="#revokeBlobURL">#</a> **revokeBlobURL** + +Remove the resource and file cache from memory. + +_Parameters_ + +- _url_ `string`: The blob URL. + + +<!-- END TOKEN(Autogenerated API docs) --> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/blob/package.json b/gb-src/packages/blob/package.json new file mode 100644 index 0000000000000..4d04f47eb2a3c --- /dev/null +++ b/gb-src/packages/blob/package.json @@ -0,0 +1,30 @@ +{ + "name": "@wordpress/blob", + "version": "2.5.1", + "description": "Blob utilities for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "blob" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/blob/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/blob" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/blob/src/index.js b/gb-src/packages/blob/src/index.js new file mode 100644 index 0000000000000..5b39dfdaf7c4a --- /dev/null +++ b/gb-src/packages/blob/src/index.js @@ -0,0 +1,61 @@ +/** + * Browser dependencies + */ +const { createObjectURL, revokeObjectURL } = window.URL; + +const cache = {}; + +/** + * Create a blob URL from a file. + * + * @param {File} file The file to create a blob URL for. + * + * @return {string} The blob URL. + */ +export function createBlobURL( file ) { + const url = createObjectURL( file ); + + cache[ url ] = file; + + return url; +} + +/** + * Retrieve a file based on a blob URL. The file must have been created by + * `createBlobURL` and not removed by `revokeBlobURL`, otherwise it will return + * `undefined`. + * + * @param {string} url The blob URL. + * + * @return {?File} The file for the blob URL. + */ +export function getBlobByURL( url ) { + return cache[ url ]; +} + +/** + * Remove the resource and file cache from memory. + * + * @param {string} url The blob URL. + */ +export function revokeBlobURL( url ) { + if ( cache[ url ] ) { + revokeObjectURL( url ); + } + + delete cache[ url ]; +} + +/** + * Check whether a url is a blob url. + * + * @param {string} url The URL. + * + * @return {boolean} Is the url a blob url? + */ +export function isBlobURL( url ) { + if ( ! url || ! url.indexOf ) { + return false; + } + return url.indexOf( 'blob:' ) === 0; +} diff --git a/gb-src/packages/blob/src/test/index.js b/gb-src/packages/blob/src/test/index.js new file mode 100644 index 0000000000000..7604e3956b6d2 --- /dev/null +++ b/gb-src/packages/blob/src/test/index.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { + isBlobURL, +} from '../'; + +describe( 'isBlobURL', () => { + it( 'returns true if the url starts with "blob:"', () => { + expect( isBlobURL( 'blob:thisbitdoesnotmatter' ) ).toBe( true ); + } ); + + it( 'returns false if the url does not start with "blob:"', () => { + expect( isBlobURL( 'https://www.example.com' ) ).toBe( false ); + } ); + + it( 'returns false if the url is not defined', () => { + expect( isBlobURL() ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/block-directory/.npmrc b/gb-src/packages/block-directory/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/block-directory/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/block-directory/CHANGELOG.md b/gb-src/packages/block-directory/CHANGELOG.md new file mode 100644 index 0000000000000..223e630eecea7 --- /dev/null +++ b/gb-src/packages/block-directory/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 (2019-09-16) + +- Initial release. diff --git a/gb-src/packages/block-directory/README.md b/gb-src/packages/block-directory/README.md new file mode 100644 index 0000000000000..16ef9ac8bc8d3 --- /dev/null +++ b/gb-src/packages/block-directory/README.md @@ -0,0 +1,17 @@ +# Block directory + +Package used to extend editor with block directory features to search and install blocks. + +> This package is meant to be used only with WordPress core. Feel free to use it in your own project but please keep in mind that it might never get fully documented. + +## Installation + +Install the module + +```bash +npm install @wordpress/block-directory --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/block-directory/package.json b/gb-src/packages/block-directory/package.json new file mode 100644 index 0000000000000..e99df78116470 --- /dev/null +++ b/gb-src/packages/block-directory/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wordpress/block-directory", + "version": "1.0.8", + "description": "Extend editor with block directory features to search, download and install blocks.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "block directory" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-directory/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-directory" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/block-editor": "file:../block-editor", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/block-directory/src/components/block-ratings/index.js b/gb-src/packages/block-directory/src/components/block-ratings/index.js new file mode 100644 index 0000000000000..ff4b8111fc7d8 --- /dev/null +++ b/gb-src/packages/block-directory/src/components/block-ratings/index.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Stars from './stars'; + +export const BlockRatings = ( { rating, ratingCount } ) => ( + <div className="block-directory-block-ratings"> + <Stars rating={ rating } /> + <span + className="block-directory-block-ratings__rating-count" + aria-label={ + // translators: %d: number of ratings (number). + sprintf( _n( '%d total rating', '%d total ratings', ratingCount ), ratingCount ) + } + > + ({ ratingCount }) + </span> + </div> +); + +export default BlockRatings; diff --git a/gb-src/packages/block-directory/src/components/block-ratings/stars.js b/gb-src/packages/block-directory/src/components/block-ratings/stars.js new file mode 100644 index 0000000000000..577c3cb15e0dc --- /dev/null +++ b/gb-src/packages/block-directory/src/components/block-ratings/stars.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Icon } from '@wordpress/components'; + +function Stars( { + rating, +} ) { + const stars = Math.round( rating / 0.5 ) * 0.5; + + const fullStarCount = Math.floor( rating ); + const halfStarCount = Math.ceil( rating - fullStarCount ); + const emptyStarCount = 5 - ( fullStarCount + halfStarCount ); + + return ( + <div aria-label={ sprintf( __( '%s out of 5 stars' ), stars ) }> + { times( fullStarCount, ( i ) => <Icon key={ `full_stars_${ i }` } icon="star-filled" size={ 16 } /> ) } + { times( halfStarCount, ( i ) => <Icon key={ `half_stars_${ i }` } icon="star-half" size={ 16 } /> ) } + { times( emptyStarCount, ( i ) => <Icon key={ `empty_stars_${ i }` } icon="star-empty" size={ 16 } /> ) } + </div> + ); +} + +export default Stars; diff --git a/gb-src/packages/block-directory/src/components/block-ratings/style.scss b/gb-src/packages/block-directory/src/components/block-ratings/style.scss new file mode 100644 index 0000000000000..1244ff8a7bf8b --- /dev/null +++ b/gb-src/packages/block-directory/src/components/block-ratings/style.scss @@ -0,0 +1,19 @@ +.block-directory-block-ratings { + display: flex; + > div { + line-height: 1; + display: flex; + } + .dashicons { + font-size: ms(-2); + width: 1.1em; + } + .block-directory-block-ratings__rating-count { + color: $dark-gray-400; + font-size: ms(-2); + } + + [class*="dashicons-star-"] { + color: #ffb900; + } +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-author-info/index.js b/gb-src/packages/block-directory/src/components/downloadable-block-author-info/index.js new file mode 100644 index 0000000000000..b422f1360416c --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-author-info/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; + +function DownloadableBlockAuthorInfo( { author, authorBlockCount, authorBlockRating } ) { + return ( + <Fragment> + <span className="block-directory-downloadable-block-author-info__content-author"> + { sprintf( __( 'Authored by %s' ), author ) } + </span> + <span className="block-directory-downloadable-block-author-info__content"> + { sprintf( + _n( + 'This author has %d block, with an average rating of %d.', + 'This author has %d blocks, with an average rating of %d.', + authorBlockCount + ), + authorBlockCount, + authorBlockRating + ) } + </span> + </Fragment> + ); +} + +export default DownloadableBlockAuthorInfo; diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-author-info/style.scss b/gb-src/packages/block-directory/src/components/downloadable-block-author-info/style.scss new file mode 100644 index 0000000000000..5692e5d66bfec --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-author-info/style.scss @@ -0,0 +1,9 @@ +.block-directory-downloadable-block-author-info__content { + color: $dark-gray-400; + margin-bottom: 4px; +} + +.block-directory-downloadable-block-author-info__content-author { + margin-bottom: 4px; + font-size: 14px; +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-header/index.js b/gb-src/packages/block-directory/src/components/downloadable-block-header/index.js new file mode 100644 index 0000000000000..7a9225d57f0cf --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-header/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { BlockIcon } from '@wordpress/block-editor'; +import BlockRatings from '../block-ratings'; + +function DownloadableBlockHeader( { icon, title, rating, ratingCount, onClick } ) { + return ( + <div className="block-directory-downloadable-block-header__row"> + { + icon.match( /\.(jpeg|jpg|gif|png)$/ ) !== null ? + <img src={ icon } alt="block icon" /> : + <span > + <BlockIcon icon={ icon } showColors /> + </span> + } + + <div className="block-directory-downloadable-block-header__column"> + <span role="heading" className="block-directory-downloadable-block-header__title" > + { title } + </span> + <BlockRatings rating={ rating } ratingCount={ ratingCount } /> + </div> + <Button + isDefault + onClick={ ( event ) => { + event.preventDefault(); + onClick(); + } } + > + { __( 'Add' ) } + </Button> + </div> + ); +} + +export default DownloadableBlockHeader; diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-header/style.scss b/gb-src/packages/block-directory/src/components/downloadable-block-header/style.scss new file mode 100644 index 0000000000000..3c297a8a9b0b5 --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-header/style.scss @@ -0,0 +1,30 @@ +.block-directory-downloadable-block-header__row { + display: flex; + flex-grow: 1; + + .block-editor-block-icon { + width: $icon-button-size; + height: $icon-button-size; + font-size: $icon-button-size; + background-color: $light-gray-300; + } + + img { + width: $icon-button-size; + height: $icon-button-size; + } + + .block-directory-downloadable-block-header__column { + display: flex; + flex-direction: column; + flex-grow: 1; + + .block-directory-downloadable-block-header__title { + font-weight: 600; + margin-left: 12px; + } + .block-directory-block-ratings { + margin-left: 12px; + } + } +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-info/index.js b/gb-src/packages/block-directory/src/components/downloadable-block-info/index.js new file mode 100644 index 0000000000000..4dbbd8f691ffa --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-info/index.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { Icon } from '@wordpress/components'; +import { __, _n, sprintf } from '@wordpress/i18n'; + +function DownloadableBlockInfo( { description, activeInstalls, humanizedUpdated } ) { + return ( + <Fragment> + <p className="block-directory-downloadable-block-info__content"> + { description } + </p> + <div className="block-directory-downloadable-block-info__row"> + <div className="block-directory-downloadable-block-info__column"> + <Icon icon="chart-line"></Icon>{ sprintf( _n( '%d active installation', '%d active installations', activeInstalls ), activeInstalls ) } + </div> + <div className="block-directory-downloadable-block-info__column"> + <Icon icon="update"></Icon><span aria-label={ sprintf( __( 'Updated %s' ), humanizedUpdated ) }>{ humanizedUpdated }</span> + </div> + </div> + </Fragment> + ); +} + +export default DownloadableBlockInfo; diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-info/style.scss b/gb-src/packages/block-directory/src/components/downloadable-block-info/style.scss new file mode 100644 index 0000000000000..ede38ab7fcf7b --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-info/style.scss @@ -0,0 +1,20 @@ +.block-directory-downloadable-block-info__content { + font-size: $default-font-size; +} + +.block-directory-downloadable-block-info__row { + display: flex; + justify-content: space-between; + color: $dark-gray-400; + margin-top: $grid-size; + + .block-directory-downloadable-block-info__column { + display: flex; + align-items: flex-start; + + .dashicon { + font-size: 16px; + margin-right: 4px; + } + } +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-list-item/index.js b/gb-src/packages/block-directory/src/components/downloadable-block-list-item/index.js new file mode 100644 index 0000000000000..c5bd8b8f0244e --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-list-item/index.js @@ -0,0 +1,56 @@ +/** + * Internal dependencies + */ +import DownloadableBlockHeader from '../downloadable-block-header'; +import DownloadableBlockAuthorInfo from '../downloadable-block-author-info'; +import DownloadableBlockInfo from '../downloadable-block-info'; + +function DownloadableBlockListItem( { + item, + onClick, +} ) { + const { + icon, + title, + description, + rating, + activeInstalls, + ratingCount, + author, + humanizedUpdated, + authorBlockCount, + authorBlockRating, + } = item; + + return ( + <li className="block-directory-downloadable-block-list-item"> + <article className="block-directory-downloadable-block-list-item__panel"> + <header className="block-directory-downloadable-block-list-item__header"> + <DownloadableBlockHeader + icon={ icon } + onClick={ onClick } + title={ title } + rating={ rating } + ratingCount={ ratingCount } + /> + </header> + <section className="block-directory-downloadable-block-list-item__body"> + <DownloadableBlockInfo + activeInstalls={ activeInstalls } + description={ description } + humanizedUpdated={ humanizedUpdated } + /> + </section> + <footer className="block-directory-downloadable-block-list-item__footer"> + <DownloadableBlockAuthorInfo + author={ author } + authorBlockCount={ authorBlockCount } + authorBlockRating={ authorBlockRating } + /> + </footer> + </article> + </li> + ); +} + +export default DownloadableBlockListItem; diff --git a/gb-src/packages/block-directory/src/components/downloadable-block-list-item/style.scss b/gb-src/packages/block-directory/src/components/downloadable-block-list-item/style.scss new file mode 100644 index 0000000000000..b32b58356c4fd --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-block-list-item/style.scss @@ -0,0 +1,49 @@ +.block-directory-downloadable-block-list-item { + width: 100%; + padding: 0; + margin: 0 0 12px; + display: flex; + flex-direction: row; + font-size: $default-font-size; + color: $dark-gray-700; + align-items: flex-start; + justify-content: center; + background: transparent; + word-break: break-word; + border-radius: $radius-round-rectangle; + border: $border-width solid $light-gray-500; + transition: all 0.05s ease-in-out; + @include reduce-motion("transition"); + position: relative; + text-align: left; + overflow: hidden; +} + +.block-directory-downloadable-block-list-item__panel { + display: flex; + flex-grow: 1; + flex-direction: column; +} + +.block-directory-downloadable-block-list-item__header { + display: flex; + flex-direction: column; + padding: 12px 12px 0; +} + +.block-directory-downloadable-block-list-item__body { + display: flex; + flex-direction: column; + padding: 12px; +} + +.block-directory-downloadable-block-list-item__footer { + display: flex; + flex-direction: column; + padding: 12px; + background-color: $light-gray-200; +} + +.block-directory-downloadable-block-list-item__content { + color: $dark-gray-400; +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-blocks-list/index.js b/gb-src/packages/block-directory/src/components/downloadable-blocks-list/index.js new file mode 100644 index 0000000000000..315403267a56c --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-blocks-list/index.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getBlockMenuDefaultClassName, unregisterBlockType } from '@wordpress/blocks'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import DownloadableBlockListItem from '../downloadable-block-list-item'; + +const DOWNLOAD_ERROR_NOTICE_ID = 'block-download-error'; +const INSTALL_ERROR_NOTICE_ID = 'block-install-error'; + +function DownloadableBlocksList( { items, onHover = noop, children, downloadAndInstallBlock } ) { + return ( + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ + <ul role="list" className="block-directory-downloadable-blocks-list"> + { items && items.map( ( item ) => + <DownloadableBlockListItem + key={ item.id } + className={ getBlockMenuDefaultClassName( item.id ) } + icons={ item.icons } + onClick={ () => { + downloadAndInstallBlock( item ); + onHover( null ); + } } + onFocus={ () => onHover( item ) } + onMouseEnter={ () => onHover( item ) } + onMouseLeave={ () => onHover( null ) } + onBlur={ () => onHover( null ) } + item={ item } + /> + ) } + { children } + </ul> + /* eslint-enable jsx-a11y/no-redundant-roles */ + ); +} + +export default compose( + withDispatch( ( dispatch, props ) => { + const { installBlock, downloadBlock } = dispatch( 'core/block-directory' ); + const { createErrorNotice, removeNotice } = dispatch( 'core/notices' ); + const { removeBlocks } = dispatch( 'core/block-editor' ); + const { onSelect } = props; + + return { + downloadAndInstallBlock: ( item ) => { + const onDownloadError = () => { + createErrorNotice( + __( 'Block previews can’t load.' ), + { + id: DOWNLOAD_ERROR_NOTICE_ID, + actions: [ + { + label: __( 'Retry' ), + onClick: () => { + removeNotice( DOWNLOAD_ERROR_NOTICE_ID ); + downloadBlock( item, onSuccess, onDownloadError ); + }, + }, + ], + } ); + }; + + const onSuccess = () => { + const createdBlock = onSelect( item ); + + const onInstallBlockError = () => { + createErrorNotice( + __( 'Block previews can\'t install.' ), + { + id: INSTALL_ERROR_NOTICE_ID, + actions: [ + { + label: __( 'Retry' ), + onClick: () => { + removeNotice( INSTALL_ERROR_NOTICE_ID ); + installBlock( item, noop, onInstallBlockError ); + }, + }, + { + label: __( 'Remove' ), + onClick: () => { + removeNotice( INSTALL_ERROR_NOTICE_ID ); + removeBlocks( createdBlock.clientId ); + unregisterBlockType( item.name ); + }, + }, + ], + } + ); + }; + + installBlock( item, noop, onInstallBlockError ); + }; + + downloadBlock( item, onSuccess, onDownloadError ); + }, + }; + } ), +)( DownloadableBlocksList ); diff --git a/gb-src/packages/block-directory/src/components/downloadable-blocks-list/style.scss b/gb-src/packages/block-directory/src/components/downloadable-blocks-list/style.scss new file mode 100644 index 0000000000000..2324e9dda5fad --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-blocks-list/style.scss @@ -0,0 +1,7 @@ +.block-directory-downloadable-blocks-list { + list-style: none; + padding: 2px 0; + overflow: hidden; + display: flex; + flex-wrap: wrap; +} diff --git a/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/index.js b/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/index.js new file mode 100644 index 0000000000000..0936ee3eea6f2 --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/index.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Spinner, withSpokenMessages } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import DownloadableBlocksList from '../downloadable-blocks-list'; + +function DownloadableBlocksPanel( { downloadableItems, onSelect, onHover, hasPermission, isLoading, isWaiting, debouncedSpeak } ) { + if ( ! hasPermission ) { + debouncedSpeak( __( 'No blocks found in your library. Please contact your site administrator to install new blocks.' ) ); + return ( + <p className="block-directory-downloadable-blocks-panel__description has-no-results"> + { __( 'No blocks found in your library.' ) } + <br /> + { __( 'Please contact your site administrator to install new blocks.' ) } + </p> + ); + } + + if ( isLoading || isWaiting ) { + return ( + <p className="block-directory-downloadable-blocks-panel__description has-no-results"> + <Spinner /> + </p> + ); + } + + if ( ! downloadableItems.length ) { + return ( + <p className="block-directory-downloadable-blocks-panel__description has-no-results"> + { __( 'No blocks found in your library.' ) } + </p> + ); + } + + const resultsFoundMessage = sprintf( + _n( 'No blocks found in your library. We did find %d block available for download.', 'No blocks found in your library. We did find %d blocks available for download.', downloadableItems.length ), + downloadableItems.length + ); + + debouncedSpeak( resultsFoundMessage ); + return ( + <Fragment> + <p className="block-directory-downloadable-blocks-panel__description"> + { __( 'No blocks found in your library. These blocks can be downloaded and installed:' ) } + </p> + <DownloadableBlocksList items={ downloadableItems } onSelect={ onSelect } onHover={ onHover } /> + </Fragment> + ); +} + +export default compose( [ + withSpokenMessages, + withSelect( ( select, { filterValue } ) => { + const { + getDownloadableBlocks, + hasInstallBlocksPermission, + isRequestingDownloadableBlocks, + } = select( 'core/block-directory' ); + + const hasPermission = hasInstallBlocksPermission(); + const downloadableItems = hasPermission ? getDownloadableBlocks( filterValue ) : []; + const isLoading = isRequestingDownloadableBlocks(); + + return { + downloadableItems, + hasPermission, + isLoading, + }; + } ), +] )( DownloadableBlocksPanel ); diff --git a/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/style.scss b/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/style.scss new file mode 100644 index 0000000000000..62a6a11c0fcab --- /dev/null +++ b/gb-src/packages/block-directory/src/components/downloadable-blocks-panel/style.scss @@ -0,0 +1,19 @@ + +.block-directory-downloadable-blocks-panel__description { + font-style: italic; + padding: 0; + margin-top: 0; + text-align: left; + color: $dark-gray-400; +} + +.block-directory-downloadable-blocks-panel__description.has-no-results { + font-style: normal; + padding: 0; + margin-top: 100px; + text-align: center; + color: $dark-gray-400; + .components-spinner { + float: inherit; + } +} diff --git a/gb-src/packages/block-directory/src/index.js b/gb-src/packages/block-directory/src/index.js new file mode 100644 index 0000000000000..5c088e8d5928d --- /dev/null +++ b/gb-src/packages/block-directory/src/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './store'; + +export { default as DownloadableBlocksPanel } from './components/downloadable-blocks-panel'; diff --git a/gb-src/packages/block-directory/src/store/actions.js b/gb-src/packages/block-directory/src/store/actions.js new file mode 100644 index 0000000000000..46bce90d2f7cb --- /dev/null +++ b/gb-src/packages/block-directory/src/store/actions.js @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import { getBlockTypes } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { apiFetch, loadAssets } from './controls'; + +/** + * Returns an action object used in signalling that the downloadable blocks have been requested and is loading. + * + * @return {Object} Action object. + */ +export function fetchDownloadableBlocks() { + return { type: 'FETCH_DOWNLOADABLE_BLOCKS' }; +} + +/** + * Returns an action object used in signalling that the downloadable blocks have been updated. + * + * @param {Array} downloadableBlocks Downloadable blocks. + * @param {string} filterValue Search string. + * + * @return {Object} Action object. + */ +export function receiveDownloadableBlocks( downloadableBlocks, filterValue ) { + return { type: 'RECEIVE_DOWNLOADABLE_BLOCKS', downloadableBlocks, filterValue }; +} + +/** + * Returns an action object used in signalling that the user does not have permission to install blocks. + * + @param {boolean} hasPermission User has permission to install blocks. + * + * @return {Object} Action object. + */ +export function setInstallBlocksPermission( hasPermission ) { + return { type: 'SET_INSTALL_BLOCKS_PERMISSION', hasPermission }; +} + +/** + * Action triggered to download block assets. + * + * @param {Object} item The selected block item + * @param {Function} onSuccess The callback function when the action has succeeded. + * @param {Function} onError The callback function when the action has failed. + */ +export function* downloadBlock( item, onSuccess, onError ) { + try { + if ( ! item.assets.length ) { + throw new Error( 'Block has no assets' ); + } + + yield loadAssets( item.assets ); + const registeredBlocks = getBlockTypes(); + if ( registeredBlocks.length ) { + onSuccess( item ); + } else { + throw new Error( 'Unable to get block types' ); + } + } catch ( error ) { + yield onError( error ); + } +} + +/** + * Action triggered to install a block plugin. + * + * @param {string} item The block item returned by search. + * @param {Function} onSuccess The callback function when the action has succeeded. + * @param {Function} onError The callback function when the action has failed. + * + */ +export function* installBlock( { id, name }, onSuccess, onError ) { + try { + const response = yield apiFetch( { + path: '__experimental/block-directory/install', + data: { + slug: id, + }, + method: 'POST', + } ); + if ( response.success === false ) { + throw new Error( response.errorMessage ); + } + yield addInstalledBlockType( { id, name } ); + onSuccess(); + } catch ( error ) { + onError( error ); + } +} + +/** + * Action triggered to uninstall a block plugin. + * + * @param {string} item The block item returned by search. + * @param {Function} onSuccess The callback function when the action has succeeded. + * @param {Function} onError The callback function when the action has failed. + * + */ +export function* uninstallBlock( { id, name }, onSuccess, onError ) { + try { + const response = yield apiFetch( { + path: '__experimental/block-directory/uninstall', + data: { + slug: id, + }, + method: 'DELETE', + } ); + if ( response.success === false ) { + throw new Error( response.errorMessage ); + } + yield removeInstalledBlockType( { id, name } ); + onSuccess(); + } catch ( error ) { + onError( error ); + } +} + +/** + * Returns an action object used to add a newly installed block type. + * + * @param {string} item The block item with the block id and name. + * + * @return {Object} Action object. + */ +export function addInstalledBlockType( item ) { + return { + type: 'ADD_INSTALLED_BLOCK_TYPE', + item, + }; +} + +/** + * Returns an action object used to remove a newly installed block type. + * + * @param {string} item The block item with the block id and name. + * + * @return {Object} Action object. + */ +export function removeInstalledBlockType( item ) { + return { + type: 'REMOVE_INSTALLED_BLOCK_TYPE', + item, + }; +} diff --git a/gb-src/packages/block-directory/src/store/controls.js b/gb-src/packages/block-directory/src/store/controls.js new file mode 100644 index 0000000000000..fbd187b7bbf41 --- /dev/null +++ b/gb-src/packages/block-directory/src/store/controls.js @@ -0,0 +1,151 @@ +/** + * External dependencies + */ +import { forEach } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; +import wpApiFetch from '@wordpress/api-fetch'; + +/** + * Calls a selector using the current state. + * + * @param {string} storeName Store name. + * @param {string} selectorName Selector name. + * @param {Array} args Selector arguments. + * + * @return {Object} Control descriptor. + */ +export function select( storeName, selectorName, ...args ) { + return { + type: 'SELECT', + storeName, + selectorName, + args, + }; +} + +/** + * Calls a dispatcher using the current state. + * + * @param {string} storeName Store name. + * @param {string} dispatcherName Dispatcher name. + * @param {Array} args Selector arguments. + * + * @return {Object} Control descriptor. + */ +export function dispatch( storeName, dispatcherName, ...args ) { + return { + type: 'DISPATCH', + storeName, + dispatcherName, + args, + }; +} + +/** + * Trigger an API Fetch request. + * + * @param {Object} request API Fetch Request Object. + * + * @return {Object} Control descriptor. + */ +export function apiFetch( request ) { + return { + type: 'API_FETCH', + request, + }; +} + +/** + * Loads JavaScript + * + * @param {Array} asset The url for the JavaScript. + * @param {Function} onLoad Callback function on success. + * @param {Function} onError Callback function on failure. + */ +const loadScript = ( asset, onLoad, onError ) => { + if ( ! asset ) { + return; + } + const existing = document.querySelector( `script[src="${ asset.src }"]` ); + if ( existing ) { + existing.parentNode.removeChild( existing ); + } + const script = document.createElement( 'script' ); + script.src = typeof asset === 'string' ? asset : asset.src; + script.onload = onLoad; + script.onerror = onError; + document.body.appendChild( script ); +}; + +/** + * Loads CSS file. + * + * @param {*} asset the url for the CSS file. + */ +const loadStyle = ( asset ) => { + if ( ! asset ) { + return; + } + const link = document.createElement( 'link' ); + link.rel = 'stylesheet'; + link.href = typeof asset === 'string' ? asset : asset.src; + document.body.appendChild( link ); +}; + +/** + * Load the asset files for a block + * + * @param {Array} assets A collection of URL for the assets. + * + * @return {Object} Control descriptor. + */ +export function* loadAssets( assets ) { + return { + type: 'LOAD_ASSETS', + assets, + }; +} + +const controls = { + SELECT: createRegistryControl( ( registry ) => ( { storeName, selectorName, args } ) => { + return registry.select( storeName )[ selectorName ]( ...args ); + } ), + DISPATCH: createRegistryControl( ( registry ) => ( { storeName, dispatcherName, args } ) => { + return registry.dispatch( storeName )[ dispatcherName ]( ...args ); + } ), + API_FETCH( { request } ) { + return wpApiFetch( { ... request } ); + }, + LOAD_ASSETS( { assets } ) { + return new Promise( ( resolve, reject ) => { + if ( Array.isArray( assets ) ) { + let scriptsCount = 0; + + forEach( assets, ( asset ) => { + if ( asset.match( /\.js$/ ) !== null ) { + scriptsCount++; + loadScript( asset, () => { + scriptsCount--; + if ( scriptsCount === 0 ) { + return resolve( scriptsCount ); + } + }, reject ); + } else { + loadStyle( asset ); + } + } ); + } else { + loadScript( assets.editor_script, () => { + return resolve( 0 ); + }, reject ); + loadStyle( assets.style ); + } + } ); + }, +}; + +export default controls; diff --git a/gb-src/packages/block-directory/src/store/index.js b/gb-src/packages/block-directory/src/store/index.js new file mode 100644 index 0000000000000..1488e7be94d82 --- /dev/null +++ b/gb-src/packages/block-directory/src/store/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import resolvers from './resolvers'; +import controls from './controls'; + +/** + * Module Constants + */ +const MODULE_KEY = 'core/block-directory'; + +/** + * Block editor data store configuration. + * + * @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore + * + * @type {Object} + */ +export const storeConfig = { + reducer, + selectors, + actions, + controls, + resolvers, +}; + +const store = registerStore( MODULE_KEY, storeConfig ); + +export default store; diff --git a/gb-src/packages/block-directory/src/store/reducer.js b/gb-src/packages/block-directory/src/store/reducer.js new file mode 100644 index 0000000000000..8dc4f3438c318 --- /dev/null +++ b/gb-src/packages/block-directory/src/store/reducer.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +/** + * Reducer returning an array of downloadable blocks. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export const downloadableBlocks = ( state = { + results: {}, + hasPermission: true, + filterValue: undefined, + isRequestingDownloadableBlocks: true, + installedBlockTypes: [], +}, action ) => { + switch ( action.type ) { + case 'FETCH_DOWNLOADABLE_BLOCKS' : + return { + ...state, + isRequestingDownloadableBlocks: true, + }; + case 'RECEIVE_DOWNLOADABLE_BLOCKS' : + return { + ...state, + results: Object.assign( {}, state.results, { + [ action.filterValue ]: action.downloadableBlocks, + } ), + hasPermission: true, + isRequestingDownloadableBlocks: false, + }; + case 'SET_INSTALL_BLOCKS_PERMISSION' : + return { + ...state, + items: action.hasPermission ? state.items : [], + hasPermission: action.hasPermission, + }; + case 'ADD_INSTALLED_BLOCK_TYPE' : + return { + ...state, + installedBlockTypes: [ ...state.installedBlockTypes, action.item ], + }; + + case 'REMOVE_INSTALLED_BLOCK_TYPE' : + return { + ...state, + installedBlockTypes: state.installedBlockTypes.filter( ( blockType ) => blockType.name !== action.item.name ), + }; + } + return state; +}; + +export default combineReducers( { + downloadableBlocks, +} ); diff --git a/gb-src/packages/block-directory/src/store/resolvers.js b/gb-src/packages/block-directory/src/store/resolvers.js new file mode 100644 index 0000000000000..885257ea72ee8 --- /dev/null +++ b/gb-src/packages/block-directory/src/store/resolvers.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { camelCase, mapKeys } from 'lodash'; + +/** + * Internal dependencies + */ +import { apiFetch } from './controls'; +import { fetchDownloadableBlocks, receiveDownloadableBlocks, setInstallBlocksPermission } from './actions'; + +export default { + * getDownloadableBlocks( filterValue ) { + if ( ! filterValue ) { + return; + } + + try { + yield fetchDownloadableBlocks( filterValue ); + const results = yield apiFetch( { + path: `__experimental/block-directory/search?term=${ filterValue }`, + } ); + const blocks = results.map( ( result ) => mapKeys( result, ( value, key ) => { + return camelCase( key ); + } ) ); + + yield receiveDownloadableBlocks( blocks, filterValue ); + } catch ( error ) { + if ( error.code === 'rest_user_cannot_view' ) { + yield setInstallBlocksPermission( false ); + } + } + }, + * hasInstallBlocksPermission() { + try { + yield apiFetch( { + path: `__experimental/block-directory/search?term=`, + } ); + yield setInstallBlocksPermission( true ); + } catch ( error ) { + if ( error.code === 'rest_user_cannot_view' ) { + yield setInstallBlocksPermission( false ); + } + } + }, +}; diff --git a/gb-src/packages/block-directory/src/store/selectors.js b/gb-src/packages/block-directory/src/store/selectors.js new file mode 100644 index 0000000000000..635bd37b81941 --- /dev/null +++ b/gb-src/packages/block-directory/src/store/selectors.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * Returns true if application is requesting for downloadable blocks. + * + * @param {Object} state Global application state. + * + * @return {Array} Downloadable blocks + */ +export function isRequestingDownloadableBlocks( state ) { + return state.downloadableBlocks.isRequestingDownloadableBlocks; +} + +/** + * Returns the available uninstalled blocks + * + * @param {Object} state Global application state. + * @param {string} filterValue Search string. + * + * @return {Array} Downloadable blocks + */ +export function getDownloadableBlocks( state, filterValue ) { + if ( ! state.downloadableBlocks.results[ filterValue ] ) { + return []; + } + return state.downloadableBlocks.results[ filterValue ]; +} + +/** + * Returns true if user has permission to install blocks. + * + * @param {Object} state Global application state. + * + * @return {boolean} User has permission to install blocks. + */ +export function hasInstallBlocksPermission( state ) { + return state.downloadableBlocks.hasPermission; +} + +/** + * Returns the block types that have been installed on the server. + * + * @param {Object} state Global application state. + * + * @return {Array} Block type items. + */ +export function getInstalledBlockTypes( state ) { + return get( state, [ 'downloadableBlocks', 'installedBlockTypes' ], [] ); +} diff --git a/gb-src/packages/block-directory/src/style.scss b/gb-src/packages/block-directory/src/style.scss new file mode 100644 index 0000000000000..14f33e678ede4 --- /dev/null +++ b/gb-src/packages/block-directory/src/style.scss @@ -0,0 +1,7 @@ +@import "./components/downloadable-block-header/style.scss"; +@import "./components/downloadable-block-info/style.scss"; +@import "./components/downloadable-block-author-info/style.scss"; +@import "./components/downloadable-block-list-item/style.scss"; +@import "./components/downloadable-blocks-list/style.scss"; +@import "./components/downloadable-blocks-panel/style.scss"; +@import "./components/block-ratings/style.scss"; diff --git a/gb-src/packages/block-editor/.npmrc b/gb-src/packages/block-editor/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/block-editor/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/block-editor/CHANGELOG.md b/gb-src/packages/block-editor/CHANGELOG.md new file mode 100644 index 0000000000000..07abd2b81221b --- /dev/null +++ b/gb-src/packages/block-editor/CHANGELOG.md @@ -0,0 +1,44 @@ +## 3.0.0 (2019-08-05) + +### New Features + +- Added a new `allowedFormats` prop to `RichText` to fine tune allowed formats. Deprecated the `formattingControls` prop in favour of this. Also added a `withoutInteractiveFormatting` to specifically disable format types that would insert interactive elements, which can not be nested. + +### Breaking Changes + +- `BlockEditorProvider` no longer renders a wrapping `SlotFillProvider` or `DropZoneProvider` (from `@wordpress/components`). For custom block editors, you should render your own as wrapping the `BlockEditorProvider`. A future release will include a new `BlockEditor` component for simple, standard usage. `BlockEditorProvider` will serve the simple purpose of establishing its own context for block editors. + +## 2.2.0 (2019-06-12) + +### Internal + +- Refactored `BlockSettingsMenu` to use `DropdownMenu` from `@wordpress/components`. + +## 2.0.0 (2019-04-16) + +### New Features + +- Added the `addToGallery` property to the `MediaUpload` interface. The property allows users to open the media modal in the `gallery-library`instead of `gallery-edit` state. +- Added the `addToGallery` property to the `MediaPlaceholder` component. The component passes the property to the `MediaUpload` component used inside the placeholder. +- Added the `isAppender` property to the `MediaPlaceholder` component. The property changes the look of the placeholder to be adequate to scenarios where new files are added to an already existing set of files, e.g., adding files to a gallery. +- Added the `dropZoneUIOnly` property to the `MediaPlaceholder` component. The property makes the `MediaPlaceholder` only render a dropzone without any other additional UI. +- Added a cancel link to the list of buttons in the `MediaPlaceholder` component which appears if an `onCancel` handler exists. +- Added the usage of `mediaPreview` for the `Placeholder` component to the `MediaPlaceholder` component. +- Added a an `onDoubleClick` event handler to the `MediaPlaceholder` component. +- Added a way to pass special `ref` property to the `PlainText` component. +- The `URLPopover` component now passes through all unhandled props to the underlying Popover component. + +### Breaking Changes + +- `CopyHandler` will now only catch cut/copy events coming from its `props.children`, instead of from anywhere in the `document`. + +### Internal + +- Improved handling of blocks state references for unchanging states. +- Updated handling of blocks state to effectively ignored programmatically-received blocks data (e.g. reusable blocks received from editor). + +## 1.0.0 (2019-03-06) + +### New Features + +- Initial version. diff --git a/gb-src/packages/block-editor/README.md b/gb-src/packages/block-editor/README.md new file mode 100644 index 0000000000000..d9c7818065c0b --- /dev/null +++ b/gb-src/packages/block-editor/README.md @@ -0,0 +1,500 @@ +# Block Editor + +This module allows you to create and use standalone block editors. + +## Installation + +Install the module + +```bash +npm install @wordpress/block-editor --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```js +import { + BlockEditorProvider, + BlockList +} from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; + +function MyEditorComponent () { + const [ blocks, updateBlocks ] = useState( [] ); + + return ( + <BlockEditorProvider + value={ blocks } + onInput={ updateBlocks } + onChange={ updateBlocks } + > + <WritingFlow> + <ObserveTyping> + <BlockList /> + </ObserveTyping> + </WritingFlow> + <Popover.Slot /> + </BlockEditorProvider> + ); +} + +// Make sure to load the block editor stylesheets too +// import '@wordpress/components/build-style/style.css'; +// import '@wordpress/block-editor/build-style/style.css'; +``` + +In this example, we're instantiating a block editor. A block editor is composed by a `BlockEditorProvider` wrapper component where you passe the current array of blocks and on each change the `onInput` or `onChange` callbacks are called depending on whether the change is considered persistent or not. + +Inside `BlockEditorProvider`, you can nest any of the available `@wordpress/block-editor` UI components to build the UI of your editor. + +In the example above we're rendering the `BlockList` to show and edit the block list. For instance we could add a custom sidebar and use the `BlockInspector` component to be able to edit the advanced settings for the currently selected block. (See the [API](#API) for the list of all the available components). + +In the example above, there's no registered block type, in order to use the block editor successfully make sure to register some block types. For instance, registering the core block types can be done like so: + +```js +import { registerCoreBlocks } from '@wordpress/block-library'; + +registerCoreBlocks(); + +// Make sure to load the block stylesheets too +// import '@wordpress/block-library/build-style/style.css'; +// import '@wordpress/block-library/build-style/editor.css'; +// import '@wordpress/block-library/build-style/theme.css'; +``` + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="AlignmentToolbar" href="#AlignmentToolbar">#</a> **AlignmentToolbar** + +Undocumented declaration. + +<a name="Autocomplete" href="#Autocomplete">#</a> **Autocomplete** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/autocomplete/README.md> + +<a name="BlockAlignmentToolbar" href="#BlockAlignmentToolbar">#</a> **BlockAlignmentToolbar** + +Undocumented declaration. + +<a name="BlockControls" href="#BlockControls">#</a> **BlockControls** + +Undocumented declaration. + +<a name="BlockEdit" href="#BlockEdit">#</a> **BlockEdit** + +Undocumented declaration. + +<a name="BlockEditorKeyboardShortcuts" href="#BlockEditorKeyboardShortcuts">#</a> **BlockEditorKeyboardShortcuts** + +Undocumented declaration. + +<a name="BlockEditorProvider" href="#BlockEditorProvider">#</a> **BlockEditorProvider** + +Undocumented declaration. + +<a name="BlockFormatControls" href="#BlockFormatControls">#</a> **BlockFormatControls** + +Undocumented declaration. + +<a name="BlockIcon" href="#BlockIcon">#</a> **BlockIcon** + +Undocumented declaration. + +<a name="BlockInspector" href="#BlockInspector">#</a> **BlockInspector** + +Undocumented declaration. + +<a name="BlockList" href="#BlockList">#</a> **BlockList** + +Undocumented declaration. + +<a name="BlockMover" href="#BlockMover">#</a> **BlockMover** + +Undocumented declaration. + +<a name="BlockNavigationDropdown" href="#BlockNavigationDropdown">#</a> **BlockNavigationDropdown** + +Undocumented declaration. + +<a name="BlockPreview" href="#BlockPreview">#</a> **BlockPreview** + +BlockPreview renders a preview of a block or array of blocks. + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/block-preview/README.md> + +_Parameters_ + +- _blocks_ `(Array|Object)`: A block instance (object) or an array of blocks to be previewed. +- _viewportWidth_ `number`: Width of the preview container in pixels. Controls at what size the blocks will be rendered inside the preview. Default: 700. + +_Returns_ + +- `WPElement`: Rendered element. + +<a name="BlockSelectionClearer" href="#BlockSelectionClearer">#</a> **BlockSelectionClearer** + +Undocumented declaration. + +<a name="BlockSettingsMenu" href="#BlockSettingsMenu">#</a> **BlockSettingsMenu** + +Undocumented declaration. + +<a name="BlockTitle" href="#BlockTitle">#</a> **BlockTitle** + +Undocumented declaration. + +<a name="BlockToolbar" href="#BlockToolbar">#</a> **BlockToolbar** + +Undocumented declaration. + +<a name="BlockVerticalAlignmentToolbar" href="#BlockVerticalAlignmentToolbar">#</a> **BlockVerticalAlignmentToolbar** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/block-vertical-alignment-toolbar/README.md> + +<a name="ButtonBlockerAppender" href="#ButtonBlockerAppender">#</a> **ButtonBlockerAppender** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/button-block-appender/README.md> + +<a name="ColorPalette" href="#ColorPalette">#</a> **ColorPalette** + +Undocumented declaration. + +<a name="ContrastChecker" href="#ContrastChecker">#</a> **ContrastChecker** + +Undocumented declaration. + +<a name="CopyHandler" href="#CopyHandler">#</a> **CopyHandler** + +Undocumented declaration. + +<a name="createCustomColorsHOC" href="#createCustomColorsHOC">#</a> **createCustomColorsHOC** + +A higher-order component factory for creating a 'withCustomColors' HOC, which handles color logic +for class generation color value, retrieval and color attribute setting. + +Use this higher-order component to work with a custom set of colors. + +_Usage_ + +```jsx +const CUSTOM_COLORS = [ { name: 'Red', slug: 'red', color: '#ff0000' }, { name: 'Blue', slug: 'blue', color: '#0000ff' } ]; +const withCustomColors = createCustomColorsHOC( CUSTOM_COLORS ); +// ... +export default compose( + withCustomColors( 'backgroundColor', 'borderColor' ), + MyColorfulComponent, +); +``` + +_Parameters_ + +- _colorsArray_ `Array`: The array of color objects (name, slug, color, etc... ). + +_Returns_ + +- `Function`: Higher-order component. + +<a name="DefaultBlockAppender" href="#DefaultBlockAppender">#</a> **DefaultBlockAppender** + +Undocumented declaration. + +<a name="FontSizePicker" href="#FontSizePicker">#</a> **FontSizePicker** + +Undocumented declaration. + +<a name="getColorClassName" href="#getColorClassName">#</a> **getColorClassName** + +Returns a class based on the context a color is being used and its slug. + +_Parameters_ + +- _colorContextName_ `string`: Context/place where color is being used e.g: background, text etc... +- _colorSlug_ `string`: Slug of the color. + +_Returns_ + +- `?string`: String with the class corresponding to the color in the provided context. Returns undefined if either colorContextName or colorSlug are not provided. + +<a name="getColorObjectByAttributeValues" href="#getColorObjectByAttributeValues">#</a> **getColorObjectByAttributeValues** + +Provided an array of color objects as set by the theme or by the editor defaults, +and the values of the defined color or custom color returns a color object describing the color. + +_Parameters_ + +- _colors_ `Array`: Array of color objects as set by the theme or by the editor defaults. +- _definedColor_ `?string`: A string containing the color slug. +- _customColor_ `?string`: A string containing the customColor value. + +_Returns_ + +- `?Object`: If definedColor is passed and the name is found in colors, the color object exactly as set by the theme or editor defaults is returned. Otherwise, an object that just sets the color is defined. + +<a name="getColorObjectByColorValue" href="#getColorObjectByColorValue">#</a> **getColorObjectByColorValue** + +Provided an array of color objects as set by the theme or by the editor defaults, and a color value returns the color object matching that value or undefined. + +_Parameters_ + +- _colors_ `Array`: Array of color objects as set by the theme or by the editor defaults. +- _colorValue_ `?string`: A string containing the color value. + +_Returns_ + +- `?Object`: Color object included in the colors array whose color property equals colorValue. Returns undefined if no color object matches this requirement. + +<a name="getFontSize" href="#getFontSize">#</a> **getFontSize** + +Returns the font size object based on an array of named font sizes and the namedFontSize and customFontSize values. + If namedFontSize is undefined or not found in fontSizes an object with just the size value based on customFontSize is returned. + +_Parameters_ + +- _fontSizes_ `Array`: Array of font size objects containing at least the "name" and "size" values as properties. +- _fontSizeAttribute_ `?string`: Content of the font size attribute (slug). +- _customFontSizeAttribute_ `?number`: Contents of the custom font size attribute (value). + +_Returns_ + +- `?string`: If fontSizeAttribute is set and an equal slug is found in fontSizes it returns the font size object for that slug. Otherwise, an object with just the size value based on customFontSize is returned. + +<a name="getFontSizeClass" href="#getFontSizeClass">#</a> **getFontSizeClass** + +Returns a class based on fontSizeName. + +_Parameters_ + +- _fontSizeSlug_ `string`: Slug of the fontSize. + +_Returns_ + +- `string`: String with the class corresponding to the fontSize passed. The class is generated by appending 'has-' followed by fontSizeSlug in kebabCase and ending with '-font-size'. + +<a name="InnerBlocks" href="#InnerBlocks">#</a> **InnerBlocks** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inner-blocks/README.md> + +<a name="Inserter" href="#Inserter">#</a> **Inserter** + +Undocumented declaration. + +<a name="InspectorAdvancedControls" href="#InspectorAdvancedControls">#</a> **InspectorAdvancedControls** + +Undocumented declaration. + +<a name="InspectorControls" href="#InspectorControls">#</a> **InspectorControls** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inspector-controls/README.md> + +<a name="MediaPlaceholder" href="#MediaPlaceholder">#</a> **MediaPlaceholder** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-placeholder/README.md> + +<a name="MediaUpload" href="#MediaUpload">#</a> **MediaUpload** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-upload/README.md> + +<a name="MediaUploadCheck" href="#MediaUploadCheck">#</a> **MediaUploadCheck** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-upload/README.md> + +<a name="MultiBlocksSwitcher" href="#MultiBlocksSwitcher">#</a> **MultiBlocksSwitcher** + +Undocumented declaration. + +<a name="MultiSelectScrollIntoView" href="#MultiSelectScrollIntoView">#</a> **MultiSelectScrollIntoView** + +Undocumented declaration. + +<a name="NavigableToolbar" href="#NavigableToolbar">#</a> **NavigableToolbar** + +Undocumented declaration. + +<a name="ObserveTyping" href="#ObserveTyping">#</a> **ObserveTyping** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/observe-typing/README.md> + +<a name="PanelColorSettings" href="#PanelColorSettings">#</a> **PanelColorSettings** + +Undocumented declaration. + +<a name="PlainText" href="#PlainText">#</a> **PlainText** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/plain-text/README.md> + +<a name="PreserveScrollInReorder" href="#PreserveScrollInReorder">#</a> **PreserveScrollInReorder** + +Undocumented declaration. + +<a name="RichText" href="#RichText">#</a> **RichText** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/rich-text/README.md> + +<a name="RichTextShortcut" href="#RichTextShortcut">#</a> **RichTextShortcut** + +Undocumented declaration. + +<a name="RichTextToolbarButton" href="#RichTextToolbarButton">#</a> **RichTextToolbarButton** + +Undocumented declaration. + +<a name="SETTINGS_DEFAULTS" href="#SETTINGS_DEFAULTS">#</a> **SETTINGS_DEFAULTS** + +The default editor settings + + alignWide boolean Enable/Disable Wide/Full Alignments + availableLegacyWidgets Array Array of objects representing the legacy widgets available. + colors Array Palette colors + disableCustomColors boolean Whether or not the custom colors are disabled + fontSizes Array Available font sizes + disableCustomFontSizes boolean Whether or not the custom font sizes are disabled + imageSizes Array Available image sizes + maxWidth number Max width to constraint resizing + allowedBlockTypes boolean|Array Allowed block types + hasFixedToolbar boolean Whether or not the editor toolbar is fixed + hasPermissionsToManageWidgets boolean Whether or not the user is able to manage widgets. + focusMode boolean Whether the focus mode is enabled or not + styles Array Editor Styles + isRTL boolean Whether the editor is in RTL mode + bodyPlaceholder string Empty post placeholder + titlePlaceholder string Empty title placeholder + codeEditingEnabled string Whether or not the user can switch to the code editor + showInserterHelpPanel boolean Whether or not the inserter help panel is shown + **experimentalCanUserUseUnfilteredHTML string Whether the user should be able to use unfiltered HTML or the HTML should be filtered e.g., to remove elements considered insecure like iframes. + **experimentalEnableLegacyWidgetBlock boolean Whether the user has enabled the Legacy Widget Block + **experimentalEnableMenuBlock boolean Whether the user has enabled the Menu Block + **experimentalBlockDirectory boolean Whether the user has enabled the Block Directory + +<a name="SkipToSelectedBlock" href="#SkipToSelectedBlock">#</a> **SkipToSelectedBlock** + +Undocumented declaration. + +<a name="storeConfig" href="#storeConfig">#</a> **storeConfig** + +Block editor data store configuration. + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore> + +_Type_ + +- `Object` + +<a name="transformStyles" href="#transformStyles">#</a> **transformStyles** + +Applies a series of CSS rule transforms to wrap selectors inside a given class and/or rewrite URLs depending on the parameters passed. + +_Parameters_ + +- _styles_ `Array`: CSS rules. +- _wrapperClassName_ `string`: Wrapper Class Name. + +_Returns_ + +- `Array`: converted rules. + +<a name="Typewriter" href="#Typewriter">#</a> **Typewriter** + +Ensures that the text selection keeps the same vertical distance from the +viewport during keyboard events within this component. The vertical distance +can vary. It is the last clicked or scrolled to position. + +<a name="URLInput" href="#URLInput">#</a> **URLInput** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-input/README.md> + +<a name="URLInputButton" href="#URLInputButton">#</a> **URLInputButton** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-input/README.md> + +<a name="URLPopover" href="#URLPopover">#</a> **URLPopover** + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-popover/README.md> + +<a name="Warning" href="#Warning">#</a> **Warning** + +Undocumented declaration. + +<a name="withColorContext" href="#withColorContext">#</a> **withColorContext** + +Undocumented declaration. + +<a name="withColors" href="#withColors">#</a> **withColors** + +A higher-order component, which handles color logic for class generation color value, retrieval and color attribute setting. + +For use with the default editor/theme color palette. + +_Usage_ + +```jsx +export default compose( + withColors( 'backgroundColor', { textColor: 'color' } ), + MyColorfulComponent, +); +``` + +_Parameters_ + +- _colorTypes_ `...(Object|string)`: The arguments can be strings or objects. If the argument is an object, it should contain the color attribute name as key and the color context as value. If the argument is a string the value should be the color attribute name, the color context is computed by applying a kebab case transform to the value. Color context represents the context/place where the color is going to be used. The class name of the color is generated using 'has' followed by the color name and ending with the color context all in kebab case e.g: has-green-background-color. + +_Returns_ + +- `Function`: Higher-order component. + +<a name="withFontSizes" href="#withFontSizes">#</a> **withFontSizes** + +Higher-order component, which handles font size logic for class generation, +font size value retrieval, and font size change handling. + +_Parameters_ + +- _fontSizeNames_ `...(Object|string)`: The arguments should all be strings. Each string contains the font size attribute name e.g: 'fontSize'. + +_Returns_ + +- `Function`: Higher-order component. + +<a name="WritingFlow" href="#WritingFlow">#</a> **WritingFlow** + +Undocumented declaration. + + +<!-- END TOKEN(Autogenerated API docs) --> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/block-editor/package.json b/gb-src/packages/block-editor/package.json new file mode 100644 index 0000000000000..538bf0f919352 --- /dev/null +++ b/gb-src/packages/block-editor/package.json @@ -0,0 +1,62 @@ +{ + "name": "@wordpress/block-editor", + "version": "3.2.7", + "description": "Generic block editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "editor", + "blocks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-editor/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-editor" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:../a11y", + "@wordpress/blob": "file:../blob", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", + "@wordpress/html-entities": "file:../html-entities", + "@wordpress/i18n": "file:../i18n", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", + "@wordpress/token-list": "file:../token-list", + "@wordpress/url": "file:../url", + "@wordpress/viewport": "file:../viewport", + "@wordpress/wordcount": "file:../wordcount", + "classnames": "^2.2.5", + "diff": "^3.5.0", + "dom-scroll-into-view": "^1.2.1", + "inherits": "^2.0.3", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "react-autosize-textarea": "^3.0.2", + "react-spring": "^8.0.19", + "redux-multi": "^0.1.12", + "refx": "^3.0.0", + "rememo": "^3.0.0", + "tinycolor2": "^1.4.1", + "traverse": "^0.6.6" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/block-editor/src/components/alignment-toolbar/index.js b/gb-src/packages/block-editor/src/components/alignment-toolbar/index.js new file mode 100644 index 0000000000000..aa0233ae0f751 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/alignment-toolbar/index.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { find } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; + +const DEFAULT_ALIGNMENT_CONTROLS = [ + { + icon: 'editor-alignleft', + title: __( 'Align Text Left' ), + align: 'left', + }, + { + icon: 'editor-aligncenter', + title: __( 'Align Text Center' ), + align: 'center', + }, + { + icon: 'editor-alignright', + title: __( 'Align Text Right' ), + align: 'right', + }, +]; + +export function AlignmentToolbar( props ) { + const { + value, + onChange, + alignmentControls = DEFAULT_ALIGNMENT_CONTROLS, + label = __( 'Change text alignment' ), + isCollapsed = true, + } = props; + + function applyOrUnset( align ) { + return () => onChange( value === align ? undefined : align ); + } + + const activeAlignment = find( alignmentControls, ( control ) => control.align === value ); + + return ( + <Toolbar + isCollapsed={ isCollapsed } + icon={ activeAlignment ? activeAlignment.icon : 'editor-alignleft' } + label={ label } + controls={ alignmentControls.map( ( control ) => { + const { align } = control; + const isActive = ( value === align ); + + return { + ...control, + isActive, + onClick: applyOrUnset( align ), + }; + } ) } + /> + ); +} + +export default AlignmentToolbar; diff --git a/gb-src/packages/block-editor/src/components/alignment-toolbar/index.native.js b/gb-src/packages/block-editor/src/components/alignment-toolbar/index.native.js new file mode 100644 index 0000000000000..fdd842d037514 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/alignment-toolbar/index.native.js @@ -0,0 +1,5 @@ +const AlignmentToolbar = () => { + return null; +}; + +export default AlignmentToolbar; diff --git a/gb-src/packages/block-editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..5ea348074add6 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlignmentToolbar should allow custom alignment controls to be specified 1`] = ` +<Toolbar + controls={ + Array [ + Object { + "align": "custom-left", + "icon": "editor-alignleft", + "isActive": false, + "onClick": [Function], + "title": "My custom left", + }, + Object { + "align": "custom-right", + "icon": "editor-aligncenter", + "isActive": true, + "onClick": [Function], + "title": "My custom right", + }, + ] + } + icon="editor-aligncenter" + isCollapsed={true} + label="Change text alignment" +/> +`; + +exports[`AlignmentToolbar should match snapshot 1`] = ` +<Toolbar + controls={ + Array [ + Object { + "align": "left", + "icon": "editor-alignleft", + "isActive": true, + "onClick": [Function], + "title": "Align Text Left", + }, + Object { + "align": "center", + "icon": "editor-aligncenter", + "isActive": false, + "onClick": [Function], + "title": "Align Text Center", + }, + Object { + "align": "right", + "icon": "editor-alignright", + "isActive": false, + "onClick": [Function], + "title": "Align Text Right", + }, + ] + } + icon="editor-alignleft" + isCollapsed={true} + label="Change text alignment" +/> +`; diff --git a/gb-src/packages/block-editor/src/components/alignment-toolbar/test/index.js b/gb-src/packages/block-editor/src/components/alignment-toolbar/test/index.js new file mode 100644 index 0000000000000..03662cfdabe81 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/alignment-toolbar/test/index.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { AlignmentToolbar } from '../'; + +describe( 'AlignmentToolbar', () => { + const alignment = 'left'; + const onChangeSpy = jest.fn(); + + const wrapper = shallow( <AlignmentToolbar value={ alignment } onChange={ onChangeSpy } /> ); + + const controls = wrapper.props().controls; + + afterEach( () => { + onChangeSpy.mockClear(); + } ); + + test( 'should match snapshot', () => { + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should call on change with undefined when a control is already active', () => { + const activeControl = controls.find( ( { isActive } ) => isActive ); + activeControl.onClick(); + + expect( activeControl.align ).toBe( alignment ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenCalledWith( undefined ); + } ); + + test( 'should call on change a new value when the control is not active', () => { + const inactiveControl = controls.find( ( { align } ) => align === 'center' ); + inactiveControl.onClick(); + + expect( inactiveControl.isActive ).toBe( false ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenCalledWith( 'center' ); + } ); + + test( 'should allow custom alignment controls to be specified', () => { + const wrapperCustomControls = shallow( + <AlignmentToolbar + value={ 'custom-right' } + onChange={ onChangeSpy } + alignmentControls={ [ + { + icon: 'editor-alignleft', + title: 'My custom left', + align: 'custom-left', + }, + { + icon: 'editor-aligncenter', + title: 'My custom right', + align: 'custom-right', + }, + ] } + /> + ); + expect( wrapperCustomControls ).toMatchSnapshot(); + const customControls = wrapperCustomControls.props().controls; + expect( customControls ).toHaveLength( 2 ); + + // should correctly call on change when right alignment is pressed (active alignment) + const rightControl = customControls.find( + ( { align } ) => align === 'custom-right' + ); + expect( rightControl.title ).toBe( 'My custom right' ); + rightControl.onClick(); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenCalledWith( undefined ); + onChangeSpy.mockClear(); + + // should correctly call on change when right alignment is pressed (inactive alignment) + const leftControl = customControls.find( + ( { align } ) => align === 'custom-left' + ); + expect( leftControl.title ).toBe( 'My custom left' ); + leftControl.onClick(); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenCalledWith( 'custom-left' ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/autocomplete/README.md b/gb-src/packages/block-editor/src/components/autocomplete/README.md new file mode 100644 index 0000000000000..46fff1bd72b35 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/autocomplete/README.md @@ -0,0 +1,6 @@ +Autocomplete +============ + +This is an Autocomplete component for use in block UI. It is based on `Autocomplete` from `@wordpress/components` and takes the same props. In addition, it passes its autocompleters through a `editor.Autocomplete.completers` filter to give developers an opportunity to override or extend them. + +The autocompleter interface is documented with the original `Autocomplete` component in `@wordpress/components`. diff --git a/gb-src/packages/block-editor/src/components/autocomplete/index.js b/gb-src/packages/block-editor/src/components/autocomplete/index.js new file mode 100644 index 0000000000000..c95279a104db2 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/autocomplete/index.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { clone } from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters, hasFilter } from '@wordpress/hooks'; +import { compose } from '@wordpress/compose'; +import { Autocomplete as OriginalAutocomplete } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { withBlockEditContext } from '../block-edit/context'; + +/** + * Wrap the default Autocomplete component with one that + * supports a filter hook for customizing its list of autocompleters. + * + * This function is exported for unit test. + * + * @param {Function} Autocomplete Original component. + * @return {Function} Wrapped component + */ +export function withFilteredAutocompleters( Autocomplete ) { + return ( props ) => { + let { completers = [] } = props; + + if ( hasFilter( 'editor.Autocomplete.completers' ) ) { + completers = applyFilters( + 'editor.Autocomplete.completers', + // Provide copies so filters may directly modify them. + completers.map( clone ), + props.blockName, + ); + } + + return ( + <Autocomplete + { ...props } + completers={ completers } + /> + ); + }; +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/autocomplete/README.md + */ +export default compose( [ + withBlockEditContext( ( { name } ) => ( { blockName: name } ) ), + withFilteredAutocompleters, +] )( OriginalAutocomplete ); diff --git a/gb-src/packages/block-editor/src/components/block-actions/index.js b/gb-src/packages/block-editor/src/components/block-actions/index.js new file mode 100644 index 0000000000000..82f12e6e25604 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-actions/index.js @@ -0,0 +1,170 @@ +/** + * External dependencies + */ +import { castArray, first, last, every } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { cloneBlock, hasBlockSupport, switchToBlockType } from '@wordpress/blocks'; + +function BlockActions( { + canDuplicate, + canInsertDefaultBlock, + children, + isLocked, + onDuplicate, + onGroup, + onInsertAfter, + onInsertBefore, + onRemove, + onUngroup, +} ) { + return children( { + canDuplicate, + canInsertDefaultBlock, + isLocked, + onDuplicate, + onGroup, + onInsertAfter, + onInsertBefore, + onRemove, + onUngroup, + } ); +} + +export default compose( [ + withSelect( ( select, props ) => { + const { + canInsertBlockType, + getBlockRootClientId, + getBlocksByClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const { getDefaultBlockName } = select( 'core/blocks' ); + + const blocks = getBlocksByClientId( props.clientIds ); + const rootClientId = getBlockRootClientId( props.clientIds[ 0 ] ); + const canDuplicate = every( blocks, ( block ) => { + return ( + !! block && + hasBlockSupport( block.name, 'multiple', true ) && + canInsertBlockType( block.name, rootClientId ) + ); + } ); + + const canInsertDefaultBlock = canInsertBlockType( + getDefaultBlockName(), + rootClientId + ); + + return { + blocks, + canDuplicate, + canInsertDefaultBlock, + extraProps: props, + isLocked: !! getTemplateLock( rootClientId ), + rootClientId, + }; + } ), + withDispatch( ( dispatch, props, { select } ) => { + const { + clientIds, + rootClientId, + blocks, + isLocked, + canDuplicate, + } = props; + + const { + insertBlocks, + multiSelect, + removeBlocks, + insertDefaultBlock, + replaceBlocks, + } = dispatch( 'core/block-editor' ); + + return { + onDuplicate() { + if ( ! canDuplicate ) { + return; + } + + const { getBlockIndex } = select( 'core/block-editor' ); + const lastSelectedIndex = getBlockIndex( last( castArray( clientIds ) ), rootClientId ); + const clonedBlocks = blocks.map( ( block ) => cloneBlock( block ) ); + insertBlocks( + clonedBlocks, + lastSelectedIndex + 1, + rootClientId + ); + if ( clonedBlocks.length > 1 ) { + multiSelect( + first( clonedBlocks ).clientId, + last( clonedBlocks ).clientId + ); + } + }, + onRemove() { + if ( ! isLocked ) { + removeBlocks( clientIds ); + } + }, + onInsertBefore() { + if ( ! isLocked ) { + const { getBlockIndex } = select( 'core/block-editor' ); + const firstSelectedIndex = getBlockIndex( first( castArray( clientIds ) ), rootClientId ); + insertDefaultBlock( {}, rootClientId, firstSelectedIndex ); + } + }, + onInsertAfter() { + if ( ! isLocked ) { + const { getBlockIndex } = select( 'core/block-editor' ); + const lastSelectedIndex = getBlockIndex( last( castArray( clientIds ) ), rootClientId ); + insertDefaultBlock( {}, rootClientId, lastSelectedIndex + 1 ); + } + }, + onGroup() { + if ( ! blocks.length ) { + return; + } + + const { + getGroupingBlockName, + } = select( 'core/blocks' ); + + const groupingBlockName = getGroupingBlockName(); + + // Activate the `transform` on `core/group` which does the conversion + const newBlocks = switchToBlockType( blocks, groupingBlockName ); + + if ( ! newBlocks ) { + return; + } + replaceBlocks( + clientIds, + newBlocks + ); + }, + + onUngroup() { + if ( ! blocks.length ) { + return; + } + + const innerBlocks = blocks[ 0 ].innerBlocks; + + if ( ! innerBlocks.length ) { + return; + } + + replaceBlocks( + clientIds, + innerBlocks + ); + }, + }; + } ), +] )( BlockActions ); diff --git a/gb-src/packages/block-editor/src/components/block-alignment-toolbar/index.js b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/index.js new file mode 100644 index 0000000000000..e766504f0d9f4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/index.js @@ -0,0 +1,84 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { withBlockEditContext } from '../block-edit/context'; + +const BLOCK_ALIGNMENTS_CONTROLS = { + left: { + icon: 'align-left', + title: __( 'Align Left' ), + }, + center: { + icon: 'align-center', + title: __( 'Align Center' ), + }, + right: { + icon: 'align-right', + title: __( 'Align Right' ), + }, + wide: { + icon: 'align-wide', + title: __( 'Wide Width' ), + }, + full: { + icon: 'align-full-width', + title: __( 'Full Width' ), + }, +}; + +const DEFAULT_CONTROLS = [ 'left', 'center', 'right', 'wide', 'full' ]; +const DEFAULT_CONTROL = 'center'; +const WIDE_CONTROLS = [ 'wide', 'full' ]; + +export function BlockAlignmentToolbar( { value, onChange, controls = DEFAULT_CONTROLS, isCollapsed = true, wideControlsEnabled = false } ) { + function applyOrUnset( align ) { + return () => onChange( value === align ? undefined : align ); + } + + const enabledControls = wideControlsEnabled ? + controls : + controls.filter( ( control ) => WIDE_CONTROLS.indexOf( control ) === -1 ); + + const activeAlignmentControl = BLOCK_ALIGNMENTS_CONTROLS[ value ]; + const defaultAlignmentControl = BLOCK_ALIGNMENTS_CONTROLS[ DEFAULT_CONTROL ]; + + return ( + <Toolbar + isCollapsed={ isCollapsed } + icon={ activeAlignmentControl ? activeAlignmentControl.icon : defaultAlignmentControl.icon } + label={ __( 'Change alignment' ) } + controls={ + enabledControls.map( ( control ) => { + return { + ...BLOCK_ALIGNMENTS_CONTROLS[ control ], + isActive: value === control, + onClick: applyOrUnset( control ), + }; + } ) + } + /> + ); +} + +export default compose( + withBlockEditContext( ( { clientId } ) => { + return { + clientId, + }; + } ), + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + const settings = getSettings(); + return { + wideControlsEnabled: settings.alignWide, + }; + } ), +)( BlockAlignmentToolbar ); diff --git a/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..e35af5e597210 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockAlignmentToolbar should match snapshot 1`] = ` +<Toolbar + controls={ + Array [ + Object { + "icon": "align-left", + "isActive": true, + "onClick": [Function], + "title": "Align Left", + }, + Object { + "icon": "align-center", + "isActive": false, + "onClick": [Function], + "title": "Align Center", + }, + Object { + "icon": "align-right", + "isActive": false, + "onClick": [Function], + "title": "Align Right", + }, + ] + } + icon="align-left" + isCollapsed={true} + label="Change alignment" +/> +`; diff --git a/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/index.js b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/index.js new file mode 100644 index 0000000000000..c7a208006849e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-alignment-toolbar/test/index.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { BlockAlignmentToolbar } from '../'; + +describe( 'BlockAlignmentToolbar', () => { + const alignment = 'left'; + const onChange = jest.fn(); + + const wrapper = shallow( <BlockAlignmentToolbar value={ alignment } onChange={ onChange } /> ); + + const controls = wrapper.props().controls; + + afterEach( () => { + onChange.mockClear(); + } ); + + test( 'should match snapshot', () => { + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should call onChange with undefined, when the control is already active', () => { + const activeControl = controls.find( ( { icon } ) => icon === `align-${ alignment }` ); + activeControl.onClick(); + + expect( activeControl.isActive ).toBe( true ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( undefined ); + } ); + + test( 'should call onChange with alignment value when the control is inactive', () => { + const inactiveCenterControl = controls.find( ( { icon } ) => icon === 'align-center' ); + inactiveCenterControl.onClick(); + + expect( inactiveCenterControl.isActive ).toBe( false ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( 'center' ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-card/index.js b/gb-src/packages/block-editor/src/components/block-card/index.js new file mode 100644 index 0000000000000..f060612622804 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-card/index.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function BlockCard( { blockType } ) { + return ( + <div className="block-editor-block-card"> + <BlockIcon icon={ blockType.icon } showColors /> + <div className="block-editor-block-card__content"> + <div className="block-editor-block-card__title">{ blockType.title }</div> + <div className="block-editor-block-card__description">{ blockType.description }</div> + </div> + </div> + ); +} + +export default BlockCard; diff --git a/gb-src/packages/block-editor/src/components/block-card/style.scss b/gb-src/packages/block-editor/src/components/block-card/style.scss new file mode 100644 index 0000000000000..6ac54020dd80a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-card/style.scss @@ -0,0 +1,34 @@ + +.block-editor-block-card { + display: flex; + align-items: flex-start; +} + +.block-editor-block-card__icon { + border: $border-width solid $light-gray-700; + padding: 7px; + margin-right: 10px; + height: 36px; + width: 36px; +} + +.block-editor-block-card__content { + flex-grow: 1; +} + +.block-editor-block-card__title { + font-weight: 500; + margin-bottom: 5px; +} + +.block-editor-block-card__description { + font-size: $default-font-size; +} + +.block-editor-block-card .block-editor-block-icon { + margin-left: -2px; + margin-right: 10px; + padding: 0 3px; + width: $icon-button-size; + height: $icon-button-size-small; +} diff --git a/gb-src/packages/block-editor/src/components/block-compare/README.md b/gb-src/packages/block-editor/src/components/block-compare/README.md new file mode 100644 index 0000000000000..3c49b7a1e9e4a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/README.md @@ -0,0 +1,43 @@ +# Block Compare + +A comparison component that shows two blocks side-by-side along with differences in HTML highlighted. This is typically used to show the current block and the results +of converting the block. + +A button is present on both blocks to then select one or the other. + +#### Props + +##### block + +The original object to compare against + +- Type: `Object` +- Required: Yes + +##### convertor + +A function that returns a new, converted, block when supplied an existing block. The conversion may fix or alter the block in a way that helps with an invalid block. + +- Type: `func` +- Required: Yes + +##### convertButtonText + +Text to show in the convert button + +- Type: `string` +- Required: Yes + +##### onKeep + +Callback when the original block is required + +- Type: `func` +- Required: Yes + +##### onConvert + +Callback when the converted block is required. + +- Type: `func` +- Required: Yes diff --git a/gb-src/packages/block-editor/src/components/block-compare/block-view.js b/gb-src/packages/block-editor/src/components/block-compare/block-view.js new file mode 100644 index 0000000000000..cb5dfc76effe4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/block-view.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { RawHTML } from '@wordpress/element'; +import { safeHTML } from '@wordpress/dom'; + +const BlockView = ( { title, rawContent, renderedContent, action, actionText, className } ) => { + return ( + <div className={ className }> + <div className="editor-block-compare__content block-editor-block-compare__content"> + <h2 className="editor-block-compare__heading block-editor-block-compare__heading">{ title }</h2> + + <div className="editor-block-compare__html block-editor-block-compare__html"> + { rawContent } + </div> + + <div className="editor-block-compare__preview block-editor-block-compare__preview edit-post-visual-editor"> + <RawHTML>{ safeHTML( renderedContent ) }</RawHTML> + </div> + </div> + + <div className="editor-block-compare__action block-editor-block-compare__action"> + <Button isLarge tabIndex="0" onClick={ action }>{ actionText }</Button> + </div> + </div> + ); +}; + +export default BlockView; diff --git a/gb-src/packages/block-editor/src/components/block-compare/index.js b/gb-src/packages/block-editor/src/components/block-compare/index.js new file mode 100644 index 0000000000000..875ea39379765 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/index.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { castArray } from 'lodash'; +import { diffChars } from 'diff'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { getSaveContent } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import BlockView from './block-view'; + +class BlockCompare extends Component { + getDifference( originalContent, newContent ) { + const difference = diffChars( originalContent, newContent ); + + return difference.map( ( item, pos ) => { + const classes = classnames( { + 'editor-block-compare__added block-editor-block-compare__added': item.added, + 'editor-block-compare__removed block-editor-block-compare__removed': item.removed, + } ); + + return <span key={ pos } className={ classes }>{ item.value }</span>; + } ); + } + + getConvertedContent( block ) { + // The convertor may return an array of items or a single item + const newBlocks = castArray( block ); + + // Get converted block details + const newContent = newBlocks.map( ( item ) => getSaveContent( item.name, item.attributes, item.innerBlocks ) ); + + return newContent.join( '' ); + } + + render() { + const { block, onKeep, onConvert, convertor, convertButtonText } = this.props; + const converted = this.getConvertedContent( convertor( block ) ); + const difference = this.getDifference( block.originalContent, converted ); + + return ( + <div className="editor-block-compare__wrapper block-editor-block-compare__wrapper"> + <BlockView + title={ __( 'Current' ) } + className="editor-block-compare__current block-editor-block-compare__current" + action={ onKeep } + actionText={ __( 'Convert to HTML' ) } + rawContent={ block.originalContent } + renderedContent={ block.originalContent } + /> + + <BlockView + title={ __( 'After Conversion' ) } + className="editor-block-compare__converted block-editor-block-compare__converted" + action={ onConvert } + actionText={ convertButtonText } + rawContent={ difference } + renderedContent={ converted } + /> + </div> + ); + } +} + +export default BlockCompare; diff --git a/gb-src/packages/block-editor/src/components/block-compare/style.scss b/gb-src/packages/block-editor/src/components/block-compare/style.scss new file mode 100644 index 0000000000000..f1be4723ce4d6 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/style.scss @@ -0,0 +1,80 @@ +/** + * Invalid block comparison + */ + +// Ensure the modal fits the content, otherwise it could be too big +.block-editor-block-compare { + overflow: auto; + height: auto; + + @include break-small() { + max-height: 70%; + } +} + +.block-editor-block-compare__wrapper { + display: flex; + padding-bottom: $panel-padding; + + > div { + display: flex; + justify-content: space-between; + flex-direction: column; + width: 50%; + padding: 0 $panel-padding 0 0; + min-width: 200px; + + button { + float: right; + } + } + + .block-editor-block-compare__converted { + border-left: 1px solid #ddd; + padding-left: 15px; + padding-right: 0; + } + + .block-editor-block-compare__html { + font-family: $editor-html-font; + font-size: 12px; + color: $dark-gray-800; + border-bottom: 1px solid #ddd; + padding-bottom: 15px; + line-height: 1.7; + + span { + background-color: #e6ffed; + padding-top: 3px; + padding-bottom: 3px; + } + + span.block-editor-block-compare__added { + background-color: #acf2bd; + } + + span.block-editor-block-compare__removed { + background-color: $alert-red; + } + } + + .block-editor-block-compare__preview { + padding: 0; + padding-top: $block-padding; + + p { + font-size: 12px; + margin-top: 0; + } + } + + .block-editor-block-compare__action { + margin-top: $block-padding; + } + + .block-editor-block-compare__heading { + font-size: 1em; + font-weight: 400; + margin: 0.67em 0; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap b/gb-src/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap new file mode 100644 index 0000000000000..21a31a25b451b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockView should match snapshot 1`] = ` +<div + className="class" +> + <div + className="editor-block-compare__content block-editor-block-compare__content" + > + <h2 + className="editor-block-compare__heading block-editor-block-compare__heading" + > + title + </h2> + <div + className="editor-block-compare__html block-editor-block-compare__html" + > + raw + </div> + <div + className="editor-block-compare__preview block-editor-block-compare__preview edit-post-visual-editor" + > + <RawHTML> + render + </RawHTML> + </div> + </div> + <div + className="editor-block-compare__action block-editor-block-compare__action" + > + <ForwardRef(Button) + isLarge={true} + onClick={[Function]} + tabIndex="0" + > + action + </ForwardRef(Button)> + </div> +</div> +`; diff --git a/gb-src/packages/block-editor/src/components/block-compare/test/block-view.js b/gb-src/packages/block-editor/src/components/block-compare/test/block-view.js new file mode 100644 index 0000000000000..657f326f44ce9 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-compare/test/block-view.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import BlockView from '../block-view'; + +describe( 'BlockView', () => { + test( 'should match snapshot', () => { + const wrapper = shallow( <BlockView title="title" rawContent="raw" renderedContent="render" action={ noop } actionText="action" className="class" /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-controls/index.js b/gb-src/packages/block-editor/src/components/block-controls/index.js new file mode 100644 index 0000000000000..1d24a7e5cd3c4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-controls/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { createSlotFill, Toolbar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ifBlockEditSelected } from '../block-edit/context'; + +const { Fill, Slot } = createSlotFill( 'BlockControls' ); + +const BlockControlsFill = ( { controls, children } ) => ( + <Fill> + <Toolbar controls={ controls } /> + { children } + </Fill> +); + +const BlockControls = ifBlockEditSelected( BlockControlsFill ); + +BlockControls.Slot = Slot; + +export default BlockControls; diff --git a/gb-src/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..ff2040497b12a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockControls should render a dynamic toolbar of controls 1`] = ` +<ContextProvider + value={ + Object { + "clientId": undefined, + "isSelected": true, + "name": undefined, + "onCaretVerticalPositionChange": undefined, + "onFocus": undefined, + } + } +> + <WithFilters(Edit) + isSelected={true} + > + <IfBlockEditSelected(BlockControlsFill) + controls={ + Array [ + Object { + "align": "left", + "icon": "editor-alignleft", + "title": "Align left", + }, + Object { + "align": "center", + "icon": "editor-aligncenter", + "title": "Align center", + }, + Object { + "align": "right", + "icon": "editor-alignright", + "title": "Align right", + }, + ] + } + > + <p> + Child + </p> + </IfBlockEditSelected(BlockControlsFill)> + </WithFilters(Edit)> +</ContextProvider> +`; diff --git a/gb-src/packages/block-editor/src/components/block-controls/test/index.js b/gb-src/packages/block-editor/src/components/block-controls/test/index.js new file mode 100644 index 0000000000000..f970dc030cadb --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-controls/test/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import BlockControls from '../'; +import BlockEdit from '../../block-edit'; + +describe( 'BlockControls', () => { + const controls = [ + { + icon: 'editor-alignleft', + title: 'Align left', + align: 'left', + }, + { + icon: 'editor-aligncenter', + title: 'Align center', + align: 'center', + }, + { + icon: 'editor-alignright', + title: 'Align right', + align: 'right', + }, + ]; + + it( 'should render a dynamic toolbar of controls', () => { + const wrapper = shallow( + <BlockEdit isSelected> + <BlockControls controls={ controls }> + <p>Child</p> + </BlockControls> + </BlockEdit> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-draggable/index.js b/gb-src/packages/block-editor/src/components/block-draggable/index.js new file mode 100644 index 0000000000000..8818d58be9c2d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-draggable/index.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { Draggable } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; + +const BlockDraggable = ( { children, clientId, rootClientId, blockElementId, index, onDragStart, onDragEnd } ) => { + const transferData = { + type: 'block', + srcIndex: index, + srcRootClientId: rootClientId, + srcClientId: clientId, + }; + + return ( + <Draggable + elementId={ blockElementId } + transferData={ transferData } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + > + { + ( { onDraggableStart, onDraggableEnd } ) => { + return children( { + onDraggableStart, + onDraggableEnd, + } ); + } + } + </Draggable> + ); +}; + +export default withSelect( ( select, { clientId } ) => { + const { getBlockIndex, getBlockRootClientId } = select( 'core/block-editor' ); + const rootClientId = getBlockRootClientId( clientId ); + return { + index: getBlockIndex( clientId, rootClientId ), + rootClientId, + }; +} )( BlockDraggable ); diff --git a/gb-src/packages/block-editor/src/components/block-drop-zone/README.md b/gb-src/packages/block-editor/src/components/block-drop-zone/README.md new file mode 100644 index 0000000000000..ed979c19c184a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-drop-zone/README.md @@ -0,0 +1,30 @@ +BlockDropZone +=========== + +`BlockDropZone` is a React component that renders a container which allows a user to drag media into the editor and immediately place it. + +## Setup + +It includes a `wp.hooks` filter `editor.BlockDropZone` that enables developers to replace or extend it. + +_Example:_ + +Replace implementation of the drop zone: + +```js +function replaceBlockDropZone() { + return function() { + return wp.element.createElement( + 'div', + {}, + 'The replacement contents or components.' + ); + } +} + +wp.hooks.addFilter( + 'editor.BlockDropZone', + 'my-plugin/replace-block-drop-zone', + replaceBlockDropZone +); +``` diff --git a/gb-src/packages/block-editor/src/components/block-drop-zone/index.js b/gb-src/packages/block-editor/src/components/block-drop-zone/index.js new file mode 100644 index 0000000000000..adac488e4c3d7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-drop-zone/index.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + DropZone, + withFilters, +} from '@wordpress/components'; +import { + pasteHandler, + getBlockTransforms, + findTransform, +} from '@wordpress/blocks'; +import { Component } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import MediaUploadCheck from '../media-upload/check'; + +const parseDropEvent = ( event ) => { + let result = { + srcRootClientId: null, + srcClientId: null, + srcIndex: null, + type: null, + }; + + if ( ! event.dataTransfer ) { + return result; + } + + try { + result = Object.assign( result, JSON.parse( event.dataTransfer.getData( 'text' ) ) ); + } catch ( err ) { + return result; + } + + return result; +}; + +class BlockDropZone extends Component { + constructor() { + super( ...arguments ); + + this.onFilesDrop = this.onFilesDrop.bind( this ); + this.onHTMLDrop = this.onHTMLDrop.bind( this ); + this.onDrop = this.onDrop.bind( this ); + } + + getInsertIndex( position ) { + const { clientId, rootClientId, getBlockIndex } = this.props; + if ( clientId !== undefined ) { + const index = getBlockIndex( clientId, rootClientId ); + return ( position && position.y === 'top' ) ? index : index + 1; + } + } + + onFilesDrop( files, position ) { + const transformation = findTransform( + getBlockTransforms( 'from' ), + ( transform ) => transform.type === 'files' && transform.isMatch( files ) + ); + + if ( transformation ) { + const insertIndex = this.getInsertIndex( position ); + const blocks = transformation.transform( files, this.props.updateBlockAttributes ); + this.props.insertBlocks( blocks, insertIndex ); + } + } + + onHTMLDrop( HTML, position ) { + const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); + + if ( blocks.length ) { + this.props.insertBlocks( blocks, this.getInsertIndex( position ) ); + } + } + + onDrop( event, position ) { + const { rootClientId: dstRootClientId, clientId: dstClientId, getClientIdsOfDescendants, getBlockIndex } = this.props; + const { srcRootClientId, srcClientId, srcIndex, type } = parseDropEvent( event ); + + const isBlockDropType = ( dropType ) => dropType === 'block'; + const isSameLevel = ( srcRoot, dstRoot ) => { + // Note that rootClientId of top-level blocks will be undefined OR a void string, + // so we also need to account for that case separately. + return ( srcRoot === dstRoot ) || ( ! srcRoot === true && ! dstRoot === true ); + }; + const isSameBlock = ( src, dst ) => src === dst; + const isSrcBlockAnAncestorOfDstBlock = ( src, dst ) => getClientIdsOfDescendants( [ src ] ).some( ( id ) => id === dst ); + + if ( ! isBlockDropType( type ) || + isSameBlock( srcClientId, dstClientId ) || + isSrcBlockAnAncestorOfDstBlock( srcClientId, dstClientId || dstRootClientId ) ) { + return; + } + + const dstIndex = dstClientId ? getBlockIndex( dstClientId, dstRootClientId ) : undefined; + const positionIndex = this.getInsertIndex( position ); + // If the block is kept at the same level and moved downwards, + // subtract to account for blocks shifting upward to occupy its old position. + const insertIndex = dstIndex && srcIndex < dstIndex && isSameLevel( srcRootClientId, dstRootClientId ) ? positionIndex - 1 : positionIndex; + this.props.moveBlockToPosition( srcClientId, srcRootClientId, insertIndex ); + } + + render() { + const { isLockedAll } = this.props; + if ( isLockedAll ) { + return null; + } + + const index = this.getInsertIndex(); + const isAppender = index === undefined; + + return ( + <MediaUploadCheck> + <DropZone + className={ classnames( 'editor-block-drop-zone block-editor-block-drop-zone', { + 'is-appender': isAppender, + } ) } + onFilesDrop={ this.onFilesDrop } + onHTMLDrop={ this.onHTMLDrop } + onDrop={ this.onDrop } + /> + </MediaUploadCheck> + ); + } +} + +export default compose( + withDispatch( ( dispatch, ownProps ) => { + const { + insertBlocks, + updateBlockAttributes, + moveBlockToPosition, + } = dispatch( 'core/block-editor' ); + + return { + insertBlocks( blocks, index ) { + const { rootClientId } = ownProps; + + insertBlocks( blocks, index, rootClientId ); + }, + updateBlockAttributes( ...args ) { + updateBlockAttributes( ...args ); + }, + moveBlockToPosition( srcClientId, srcRootClientId, dstIndex ) { + const { rootClientId: dstRootClientId } = ownProps; + moveBlockToPosition( srcClientId, srcRootClientId, dstRootClientId, dstIndex ); + }, + }; + } ), + withSelect( ( select, { rootClientId } ) => { + const { getClientIdsOfDescendants, getTemplateLock, getBlockIndex } = select( 'core/block-editor' ); + return { + isLockedAll: getTemplateLock( rootClientId ) === 'all', + getClientIdsOfDescendants, + getBlockIndex, + }; + } ), + withFilters( 'editor.BlockDropZone' ) +)( BlockDropZone ); diff --git a/gb-src/packages/block-editor/src/components/block-drop-zone/style.scss b/gb-src/packages/block-editor/src/components/block-drop-zone/style.scss new file mode 100644 index 0000000000000..8135c66f32ffa --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-drop-zone/style.scss @@ -0,0 +1,27 @@ +// Dropzones +.block-editor-block-drop-zone { + border: none; + border-radius: 0; + + .components-drop-zone__content, + &.is-dragging-over-element .components-drop-zone__content { + display: none; + } + + &.is-close-to-bottom, + &.is-close-to-top { + background: none; + } + + &.is-close-to-top { + border-top: 3px solid theme(primary); + } + + &.is-close-to-bottom { + border-bottom: 3px solid theme(primary); + } + + &.is-appender.is-active.is-dragging-over-document { + border-bottom: none; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-edit/context.js b/gb-src/packages/block-editor/src/components/block-edit/context.js new file mode 100644 index 0000000000000..863cdc3e5d6fa --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-edit/context.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +const { Consumer, Provider } = createContext( { + name: '', + isSelected: false, + focusedElement: null, + setFocusedElement: noop, + clientId: null, +} ); + +export { Provider as BlockEditContextProvider }; + +/** + * A Higher Order Component used to inject BlockEdit context to the + * wrapped component. + * + * @param {Function} mapContextToProps Function called on every context change, + * expected to return object of props to + * merge with the component's own props. + * + * @return {Component} Enhanced component with injected context as props. + */ +export const withBlockEditContext = ( mapContextToProps ) => createHigherOrderComponent( ( OriginalComponent ) => { + return ( props ) => ( + <Consumer> + { ( context ) => ( + <OriginalComponent + { ...props } + { ...mapContextToProps( context, props ) } + /> + ) } + </Consumer> + ); +}, 'withBlockEditContext' ); + +/** + * A Higher Order Component used to render conditionally the wrapped + * component only when the BlockEdit has selected state set. + * + * @param {Component} OriginalComponent Component to wrap. + * + * @return {Component} Component which renders only when the BlockEdit is selected. + */ +export const ifBlockEditSelected = createHigherOrderComponent( ( OriginalComponent ) => { + return ( props ) => ( + <Consumer> + { ( { isSelected } ) => isSelected && ( + <OriginalComponent { ...props } /> + ) } + </Consumer> + ); +}, 'ifBlockEditSelected' ); diff --git a/gb-src/packages/block-editor/src/components/block-edit/edit.js b/gb-src/packages/block-editor/src/components/block-edit/edit.js new file mode 100644 index 0000000000000..c7e0ba97e305e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-edit/edit.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withFilters } from '@wordpress/components'; +import { getBlockDefaultClassName, hasBlockSupport, getBlockType } from '@wordpress/blocks'; + +export const Edit = ( props ) => { + const { attributes = {}, name } = props; + const blockType = getBlockType( name ); + + if ( ! blockType ) { + return null; + } + + // Generate a class name for the block's editable form + const generatedClassName = hasBlockSupport( blockType, 'className', true ) ? + getBlockDefaultClassName( name ) : + null; + const className = classnames( generatedClassName, attributes.className ); + + // `edit` and `save` are functions or components describing the markup + // with which a block is displayed. If `blockType` is valid, assign + // them preferentially as the render value for the block. + const Component = blockType.edit || blockType.save; + + return <Component { ...props } className={ className } />; +}; + +export default withFilters( 'editor.BlockEdit' )( Edit ); diff --git a/gb-src/packages/block-editor/src/components/block-edit/edit.native.js b/gb-src/packages/block-editor/src/components/block-edit/edit.native.js new file mode 100644 index 0000000000000..8a4d0ad8350e9 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-edit/edit.native.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { withFilters } from '@wordpress/components'; +import { getBlockType } from '@wordpress/blocks'; + +export const Edit = ( props ) => { + const { name } = props; + const blockType = getBlockType( name ); + + if ( ! blockType ) { + return null; + } + + const Component = blockType.edit; + + return ( + <Component { ...props } /> + ); +}; + +export default withFilters( 'editor.BlockEdit' )( Edit ); diff --git a/gb-src/packages/block-editor/src/components/block-edit/index.js b/gb-src/packages/block-editor/src/components/block-edit/index.js new file mode 100644 index 0000000000000..63c475a50692f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-edit/index.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import memize from 'memize'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Edit from './edit'; +import { BlockEditContextProvider } from './context'; + +class BlockEdit extends Component { + constructor() { + super( ...arguments ); + + // It is important to return the same object if props haven't changed + // to avoid unnecessary rerenders. + // See https://reactjs.org/docs/context.html#caveats. + this.propsToContext = memize( + this.propsToContext.bind( this ), + { maxSize: 1 } + ); + } + + propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange ) { + return { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange }; + } + + render() { + const { name, isSelected, clientId, onFocus, onCaretVerticalPositionChange } = this.props; + const value = this.propsToContext( name, isSelected, clientId, onFocus, onCaretVerticalPositionChange ); + + return ( + <BlockEditContextProvider value={ value }> + <Edit { ...this.props } /> + </BlockEditContextProvider> + ); + } +} + +export default BlockEdit; diff --git a/gb-src/packages/block-editor/src/components/block-edit/test/edit.js b/gb-src/packages/block-editor/src/components/block-edit/test/edit.js new file mode 100644 index 0000000000000..d09ce608cc14b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-edit/test/edit.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + getBlockTypes, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { Edit } from '../edit'; + +describe( 'Edit', () => { + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'should return null if block type not defined', () => { + const wrapper = shallow( <Edit name="core/test-block" /> ); + + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'should use edit implementation of block', () => { + const edit = () => <div />; + registerBlockType( 'core/test-block', { + save: noop, + category: 'common', + title: 'block title', + edit, + } ); + + const wrapper = shallow( <Edit name="core/test-block" /> ); + + expect( wrapper.exists( edit ) ).toBe( true ); + } ); + + it( 'should use save implementation of block as fallback', () => { + const save = () => <div />; + registerBlockType( 'core/test-block', { + save, + category: 'common', + title: 'block title', + } ); + + const wrapper = shallow( <Edit name="core/test-block" /> ); + + expect( wrapper.exists( save ) ).toBe( true ); + } ); + + it( 'should combine the default class name with a custom one', () => { + const edit = ( { className } ) => <div className={ className } />; + const attributes = { + className: 'my-class', + }; + registerBlockType( 'core/test-block', { + edit, + save: noop, + category: 'common', + title: 'block title', + } ); + + const wrapper = shallow( + <Edit name="core/test-block" attributes={ attributes } /> + ); + + expect( wrapper.find( edit ).hasClass( 'wp-block-test-block' ) ).toBe( true ); + expect( wrapper.find( edit ).hasClass( 'my-class' ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js b/gb-src/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js new file mode 100644 index 0000000000000..7e9a433182eba --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { first, last, some, flow } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { KeyboardShortcuts } from '@wordpress/components'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { rawShortcut, displayShortcut } from '@wordpress/keycodes'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockActions from '../block-actions'; + +const preventDefault = ( event ) => { + event.preventDefault(); + return event; +}; + +export const shortcuts = { + duplicate: { + raw: rawShortcut.primaryShift( 'd' ), + display: displayShortcut.primaryShift( 'd' ), + }, + removeBlock: { + raw: rawShortcut.access( 'z' ), + display: displayShortcut.access( 'z' ), + }, + insertBefore: { + raw: rawShortcut.primaryAlt( 't' ), + display: displayShortcut.primaryAlt( 't' ), + }, + insertAfter: { + raw: rawShortcut.primaryAlt( 'y' ), + display: displayShortcut.primaryAlt( 'y' ), + }, +}; + +class BlockEditorKeyboardShortcuts extends Component { + constructor() { + super( ...arguments ); + + this.selectAll = this.selectAll.bind( this ); + this.deleteSelectedBlocks = this.deleteSelectedBlocks.bind( this ); + this.clearMultiSelection = this.clearMultiSelection.bind( this ); + } + + selectAll( event ) { + const { rootBlocksClientIds, onMultiSelect } = this.props; + event.preventDefault(); + onMultiSelect( first( rootBlocksClientIds ), last( rootBlocksClientIds ) ); + } + + deleteSelectedBlocks( event ) { + const { selectedBlockClientIds, hasMultiSelection, onRemove, isLocked } = this.props; + if ( hasMultiSelection ) { + event.preventDefault(); + if ( ! isLocked ) { + onRemove( selectedBlockClientIds ); + } + } + } + + /** + * Clears current multi-selection, if one exists. + */ + clearMultiSelection() { + const { hasMultiSelection, clearSelectedBlock } = this.props; + if ( hasMultiSelection ) { + clearSelectedBlock(); + window.getSelection().removeAllRanges(); + } + } + + render() { + const { selectedBlockClientIds } = this.props; + return ( + <> + <KeyboardShortcuts + shortcuts={ { + [ rawShortcut.primary( 'a' ) ]: this.selectAll, + backspace: this.deleteSelectedBlocks, + del: this.deleteSelectedBlocks, + escape: this.clearMultiSelection, + } } + /> + { selectedBlockClientIds.length > 0 && ( + <BlockActions clientIds={ selectedBlockClientIds }> + { ( { onDuplicate, onRemove, onInsertAfter, onInsertBefore } ) => ( + <KeyboardShortcuts + bindGlobal + shortcuts={ { + // Prevents bookmark all Tabs shortcut in Chrome when devtools are closed. + // Prevents reposition Chrome devtools pane shortcut when devtools are open. + [ shortcuts.duplicate.raw ]: flow( preventDefault, onDuplicate ), + + // Does not clash with any known browser/native shortcuts, but preventDefault + // is used to prevent any obscure unknown shortcuts from triggering. + [ shortcuts.removeBlock.raw ]: flow( preventDefault, onRemove ), + + // Prevent 'view recently closed tabs' in Opera using preventDefault. + [ shortcuts.insertBefore.raw ]: flow( preventDefault, onInsertBefore ), + + // Does not clash with any known browser/native shortcuts, but preventDefault + // is used to prevent any obscure unknown shortcuts from triggering. + [ shortcuts.insertAfter.raw ]: flow( preventDefault, onInsertAfter ), + } } + /> + ) } + </BlockActions> + ) } + </> + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + getBlockOrder, + getSelectedBlockClientIds, + hasMultiSelection, + getBlockRootClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const selectedBlockClientIds = getSelectedBlockClientIds(); + + return { + rootBlocksClientIds: getBlockOrder(), + hasMultiSelection: hasMultiSelection(), + isLocked: some( + selectedBlockClientIds, + ( clientId ) => !! getTemplateLock( getBlockRootClientId( clientId ) ) + ), + selectedBlockClientIds, + }; + } ), + withDispatch( ( dispatch ) => { + const { + clearSelectedBlock, + multiSelect, + removeBlocks, + } = dispatch( 'core/block-editor' ); + + return { + clearSelectedBlock, + onMultiSelect: multiSelect, + onRemove: removeBlocks, + }; + } ), +] )( BlockEditorKeyboardShortcuts ); diff --git a/gb-src/packages/block-editor/src/components/block-format-controls/index.js b/gb-src/packages/block-editor/src/components/block-format-controls/index.js new file mode 100644 index 0000000000000..2ab83843846ad --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-format-controls/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ifBlockEditSelected } from '../block-edit/context'; + +const { Fill, Slot } = createSlotFill( 'BlockFormatControls' ); + +const BlockFormatControls = ifBlockEditSelected( Fill ); + +BlockFormatControls.Slot = Slot; + +export default BlockFormatControls; diff --git a/gb-src/packages/block-editor/src/components/block-icon/index.js b/gb-src/packages/block-editor/src/components/block-icon/index.js new file mode 100644 index 0000000000000..4d6106214df7d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-icon/index.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Path, Icon, SVG } from '@wordpress/components'; + +export default function BlockIcon( { icon, showColors = false, className } ) { + if ( get( icon, [ 'src' ] ) === 'block-default' ) { + icon = { + src: <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M19 7h-1V5h-4v2h-4V5H6v2H5c-1.1 0-2 .9-2 2v10h18V9c0-1.1-.9-2-2-2zm0 10H5V9h14v8z" /></SVG>, + }; + } + + const renderedIcon = <Icon icon={ icon && icon.src ? icon.src : icon } />; + const style = showColors ? { + backgroundColor: icon && icon.background, + color: icon && icon.foreground, + } : {}; + + return ( + <span + style={ style } + className={ classnames( + 'editor-block-icon block-editor-block-icon', + className, + { 'has-colors': showColors } + ) } + > + { renderedIcon } + </span> + ); +} diff --git a/gb-src/packages/block-editor/src/components/block-icon/style.scss b/gb-src/packages/block-editor/src/components/block-icon/style.scss new file mode 100644 index 0000000000000..07eff392cca18 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-icon/style.scss @@ -0,0 +1,27 @@ +.block-editor-block-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: 0; + border-radius: 4px; + + &.has-colors { + svg { + fill: currentColor; + } + } + + // Icons with width/height attributes below 20px will be sized up to 20px, + // and icons with width/height attributes above 24px will be sized down to + // 24px. Icons with width/height >=20px and <=24px will display at the + // indicated size. + // See: https://github.com/WordPress/gutenberg/pull/9828 + svg { + min-width: 20px; + min-height: 20px; + max-width: 24px; + max-height: 24px; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-icon/test/index.js b/gb-src/packages/block-editor/src/components/block-icon/test/index.js new file mode 100644 index 0000000000000..8c74330ad34d4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-icon/test/index.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { Icon } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import BlockIcon from '../'; + +describe( 'BlockIcon', () => { + it( 'renders a Icon', () => { + const wrapper = shallow( <BlockIcon icon="format-image" /> ); + + expect( wrapper.containsMatchingElement( <Icon icon="format-image" /> ) ).toBe( true ); + } ); + + it( 'renders a span without the has-colors classname', () => { + const wrapper = shallow( <BlockIcon icon="format-image" /> ); + + expect( wrapper.find( 'span' ).hasClass( 'has-colors' ) ).toBe( false ); + } ); + + it( 'renders a span with the has-colors classname', () => { + const wrapper = shallow( <BlockIcon icon="format-image" showColors /> ); + + expect( wrapper.find( 'span' ).hasClass( 'has-colors' ) ).toBe( true ); + } ); + + it( 'skips adding background and foreground styles when colors are not enabled', () => { + const wrapper = shallow( <BlockIcon icon={ { background: 'white', foreground: 'black' } } /> ); + + expect( wrapper.find( 'span' ).prop( 'style' ) ).toEqual( {} ); + } ); + + it( 'adds background and foreground styles when colors are enabled', () => { + const wrapper = shallow( <BlockIcon icon={ { background: 'white', foreground: 'black' } } showColors /> ); + + expect( wrapper.find( 'span' ).prop( 'style' ) ).toEqual( { + backgroundColor: 'white', + color: 'black', + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-inspector/index.js b/gb-src/packages/block-editor/src/components/block-inspector/index.js new file mode 100644 index 0000000000000..f395bb24f0f47 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-inspector/index.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { getBlockType, getUnregisteredTypeHandlerName } from '@wordpress/blocks'; +import { PanelBody } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import SkipToSelectedBlock from '../skip-to-selected-block'; +import BlockCard from '../block-card'; +import InspectorControls from '../inspector-controls'; +import InspectorAdvancedControls from '../inspector-advanced-controls'; +import BlockStyles from '../block-styles'; +import MultiSelectionInspector from '../multi-selection-inspector'; +import DefaultStylePicker from '../default-style-picker'; +const BlockInspector = ( { + blockType, + count, + hasBlockStyles, + selectedBlockClientId, + selectedBlockName, + showNoBlockSelectedMessage = true, +} ) => { + if ( count > 1 ) { + return <MultiSelectionInspector />; + } + + const isSelectedBlockUnregistered = selectedBlockName === getUnregisteredTypeHandlerName(); + + /* + * If the selected block is of an unregistered type, avoid showing it as an actual selection + * because we want the user to focus on the unregistered block warning, not block settings. + */ + if ( ! blockType || ! selectedBlockClientId || isSelectedBlockUnregistered ) { + if ( showNoBlockSelectedMessage ) { + return ( + <span className="editor-block-inspector__no-blocks block-editor-block-inspector__no-blocks"> + { __( 'No block selected.' ) } + </span> + ); + } + return null; + } + + return ( + <> + <BlockCard blockType={ blockType } /> + { hasBlockStyles && ( + <div> + <PanelBody + title={ __( 'Styles' ) } + initialOpen={ false } + > + <BlockStyles + clientId={ selectedBlockClientId } + /> + <DefaultStylePicker blockName={ blockType.name } /> + </PanelBody> + </div> + ) } + <div><InspectorControls.Slot /></div> + <div> + <InspectorAdvancedControls.Slot> + { ( fills ) => ! isEmpty( fills ) && ( + <PanelBody + className="editor-block-inspector__advanced block-editor-block-inspector__advanced" + title={ __( 'Advanced' ) } + initialOpen={ false } + > + { fills } + </PanelBody> + ) } + </InspectorAdvancedControls.Slot> + </div> + <SkipToSelectedBlock key="back" /> + </> + ); +}; + +export default withSelect( + ( select ) => { + const { getSelectedBlockClientId, getSelectedBlockCount, getBlockName } = select( 'core/block-editor' ); + const { getBlockStyles } = select( 'core/blocks' ); + const selectedBlockClientId = getSelectedBlockClientId(); + const selectedBlockName = selectedBlockClientId && getBlockName( selectedBlockClientId ); + const blockType = selectedBlockClientId && getBlockType( selectedBlockName ); + const blockStyles = selectedBlockClientId && getBlockStyles( selectedBlockName ); + return { + count: getSelectedBlockCount(), + hasBlockStyles: blockStyles && blockStyles.length > 0, + selectedBlockName, + selectedBlockClientId, + blockType, + }; + } +)( BlockInspector ); diff --git a/gb-src/packages/block-editor/src/components/block-inspector/style.scss b/gb-src/packages/block-editor/src/components/block-inspector/style.scss new file mode 100644 index 0000000000000..6b054c1232b41 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-inspector/style.scss @@ -0,0 +1,7 @@ +.block-editor-block-inspector__no-blocks { + display: block; + font-size: $default-font-size; + background: $white; + padding: ($panel-padding * 2) $panel-padding; + text-align: center; +} diff --git a/gb-src/packages/block-editor/src/components/block-list-appender/index.js b/gb-src/packages/block-editor/src/components/block-list-appender/index.js new file mode 100644 index 0000000000000..ddc22b3b928bb --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list-appender/index.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { last } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { getDefaultBlockName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import IgnoreNestedEvents from '../ignore-nested-events'; +import DefaultBlockAppender from '../default-block-appender'; +import ButtonBlockAppender from '../button-block-appender'; + +function BlockListAppender( { + blockClientIds, + rootClientId, + canInsertDefaultBlock, + isLocked, + renderAppender: CustomAppender, +} ) { + if ( isLocked ) { + return null; + } + + // If a render prop has been provided + // use it to render the appender. + if ( CustomAppender ) { + return ( + <div className="block-list-appender"> + <CustomAppender /> + </div> + ); + } + + // a false value means, don't render any appender. + if ( CustomAppender === false ) { + return null; + } + + // Render the default block appender when renderAppender has not been + // provided and the context supports use of the default appender. + if ( canInsertDefaultBlock ) { + return ( + <div className="block-list-appender"> + <IgnoreNestedEvents childHandledEvents={ [ 'onFocus', 'onClick', 'onKeyDown' ] }> + <DefaultBlockAppender + rootClientId={ rootClientId } + lastBlockClientId={ last( blockClientIds ) } + /> + </IgnoreNestedEvents> + </div> + ); + } + + // Fallback in the case no renderAppender has been provided and the + // default block can't be inserted. + return ( + <div className="block-list-appender"> + <ButtonBlockAppender + rootClientId={ rootClientId } + className="block-list-appender__toggle" + /> + </div> + ); +} + +export default withSelect( ( select, { rootClientId } ) => { + const { + getBlockOrder, + canInsertBlockType, + getTemplateLock, + } = select( 'core/block-editor' ); + + return { + isLocked: !! getTemplateLock( rootClientId ), + blockClientIds: getBlockOrder( rootClientId ), + canInsertDefaultBlock: canInsertBlockType( getDefaultBlockName(), rootClientId ), + }; +} )( BlockListAppender ); diff --git a/gb-src/packages/block-editor/src/components/block-list-appender/style.scss b/gb-src/packages/block-editor/src/components/block-list-appender/style.scss new file mode 100644 index 0000000000000..5f83d4b44b290 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list-appender/style.scss @@ -0,0 +1,15 @@ +// These styles are only applied to the appender when it appears inside of a block. +// Otherwise the default appender may be improperly positioned in some themes. +.block-editor-block-list__block .block-list-appender { + margin: $block-padding; + + // Add additional margin to the appender when inside a group with a background color. + // If changing this, be sure to sync up with group/editor.scss line 13. + .has-background & { + margin: ($block-padding*2 + $block-spacing) $block-padding; + } +} + +.block-list-appender > .block-editor-inserter { + display: block; +} diff --git a/gb-src/packages/block-editor/src/components/block-list/block-async-mode-provider.js b/gb-src/packages/block-editor/src/components/block-list/block-async-mode-provider.js new file mode 100644 index 0000000000000..aaa2e709db92c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-async-mode-provider.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { + __experimentalAsyncModeProvider as AsyncModeProvider, + useSelect, +} from '@wordpress/data'; + +const BlockAsyncModeProvider = ( { children, clientId, isBlockInSelection } ) => { + const isParentOfSelectedBlock = useSelect( ( select ) => { + return select( 'core/block-editor' ).hasSelectedInnerBlock( clientId, true ); + } ); + + const isSyncModeForced = isBlockInSelection || isParentOfSelectedBlock; + + return ( + <AsyncModeProvider value={ ! isSyncModeForced }> + { children } + </AsyncModeProvider> + ); +}; + +export default BlockAsyncModeProvider; diff --git a/gb-src/packages/block-editor/src/components/block-list/block-contextual-toolbar.js b/gb-src/packages/block-editor/src/components/block-list/block-contextual-toolbar.js new file mode 100644 index 0000000000000..36575d90f9ab5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-contextual-toolbar.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import NavigableToolbar from '../navigable-toolbar'; +import { BlockToolbar } from '../'; + +function BlockContextualToolbar( { focusOnMount } ) { + return ( + <NavigableToolbar + focusOnMount={ focusOnMount } + className="editor-block-contextual-toolbar block-editor-block-contextual-toolbar" + /* translators: accessibility text for the block toolbar */ + aria-label={ __( 'Block tools' ) } + > + <BlockToolbar /> + </NavigableToolbar> + ); +} + +export default BlockContextualToolbar; diff --git a/gb-src/packages/block-editor/src/components/block-list/block-crash-boundary.js b/gb-src/packages/block-editor/src/components/block-list/block-crash-boundary.js new file mode 100644 index 0000000000000..f5df75637721f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-crash-boundary.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +class BlockCrashBoundary extends Component { + constructor() { + super( ...arguments ); + + this.state = { + hasError: false, + }; + } + + componentDidCatch( error ) { + this.props.onError( error ); + + this.setState( { + hasError: true, + } ); + } + + render() { + if ( this.state.hasError ) { + return null; + } + + return this.props.children; + } +} + +export default BlockCrashBoundary; diff --git a/gb-src/packages/block-editor/src/components/block-list/block-crash-warning.js b/gb-src/packages/block-editor/src/components/block-list/block-crash-warning.js new file mode 100644 index 0000000000000..3a1b2100f55c3 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-crash-warning.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Warning from '../warning'; + +const warning = ( + <Warning className="block-editor-block-list__block-crash-warning"> + { __( 'This block has encountered an error and cannot be previewed.' ) } + </Warning> +); + +export default () => warning; diff --git a/gb-src/packages/block-editor/src/components/block-list/block-html.js b/gb-src/packages/block-editor/src/components/block-list/block-html.js new file mode 100644 index 0000000000000..ce70122701828 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-html.js @@ -0,0 +1,77 @@ + +/** + * External dependencies + */ +import TextareaAutosize from 'react-autosize-textarea'; +import { isEqual } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { getBlockAttributes, getBlockContent, getBlockType, isValidBlockContent, getSaveContent } from '@wordpress/blocks'; +import { withSelect, withDispatch } from '@wordpress/data'; + +export class BlockHTML extends Component { + constructor( props ) { + super( ...arguments ); + this.onChange = this.onChange.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.state = { + html: props.block.isValid ? getBlockContent( props.block ) : props.block.originalContent, + }; + } + + componentDidUpdate( prevProps ) { + if ( ! isEqual( this.props.block.attributes, prevProps.block.attributes ) ) { + this.setState( { + html: getBlockContent( this.props.block ), + } ); + } + } + + onBlur() { + const { html } = this.state; + const blockType = getBlockType( this.props.block.name ); + const attributes = getBlockAttributes( blockType, html, this.props.block.attributes ); + + // If html is empty we reset the block to the default HTML and mark it as valid to avoid triggering an error + const content = html ? html : getSaveContent( blockType, attributes ); + const isValid = html ? isValidBlockContent( blockType, attributes, content ) : true; + + this.props.onChange( this.props.clientId, attributes, content, isValid ); + + // Ensure the state is updated if we reset so it displays the default content + if ( ! html ) { + this.setState( { html: content } ); + } + } + + onChange( event ) { + this.setState( { html: event.target.value } ); + } + + render() { + const { html } = this.state; + return ( + <TextareaAutosize + className="editor-block-list__block-html-textarea block-editor-block-list__block-html-textarea" + value={ html } + onBlur={ this.onBlur } + onChange={ this.onChange } + /> + ); + } +} + +export default compose( [ + withSelect( ( select, ownProps ) => ( { + block: select( 'core/block-editor' ).getBlock( ownProps.clientId ), + } ) ), + withDispatch( ( dispatch ) => ( { + onChange( clientId, attributes, originalContent, isValid ) { + dispatch( 'core/block-editor' ).updateBlock( clientId, { attributes, originalContent, isValid } ); + }, + } ) ), +] )( BlockHTML ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.js b/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.js new file mode 100644 index 0000000000000..f28c422405835 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { Button, Modal } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { + getBlockType, + createBlock, + rawHandler, +} from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import Warning from '../warning'; +import BlockCompare from '../block-compare'; + +export class BlockInvalidWarning extends Component { + constructor( props ) { + super( props ); + + this.state = { compare: false }; + this.onCompare = this.onCompare.bind( this ); + this.onCompareClose = this.onCompareClose.bind( this ); + } + + onCompare() { + this.setState( { compare: true } ); + } + + onCompareClose() { + this.setState( { compare: false } ); + } + + render() { + const { convertToHTML, convertToBlocks, convertToClassic, attemptBlockRecovery, block } = this.props; + const hasHTMLBlock = !! getBlockType( 'core/html' ); + const { compare } = this.state; + const hiddenActions = [ + { title: __( 'Convert to Classic Block' ), onClick: convertToClassic }, + { title: __( 'Attempt Block Recovery' ), onClick: attemptBlockRecovery }, + ]; + + return ( + <> + <Warning + actions={ [ + <Button key="convert" onClick={ this.onCompare } isLarge isPrimary={ ! hasHTMLBlock }> + { + // translators: Button to fix block content + _x( 'Resolve', 'imperative verb' ) + } + </Button>, + hasHTMLBlock && ( + <Button key="edit" onClick={ convertToHTML } isLarge isPrimary> + { __( 'Convert to HTML' ) } + </Button> + ), + ] } + secondaryActions={ hiddenActions } + > + { __( 'This block contains unexpected or invalid content.' ) } + </Warning> + { compare && ( + <Modal + title={ + // translators: Dialog title to fix block content + __( 'Resolve Block' ) + } + onRequestClose={ this.onCompareClose } + className="editor-block-compare block-editor-block-compare" + > + <BlockCompare + block={ block } + onKeep={ convertToHTML } + onConvert={ convertToBlocks } + convertor={ blockToBlocks } + convertButtonText={ __( 'Convert to Blocks' ) } + /> + </Modal> + ) } + </> + ); + } +} + +const blockToClassic = ( block ) => createBlock( 'core/freeform', { + content: block.originalContent, +} ); +const blockToHTML = ( block ) => createBlock( 'core/html', { + content: block.originalContent, +} ); +const blockToBlocks = ( block ) => rawHandler( { + HTML: block.originalContent, +} ); +const recoverBlock = ( { name, attributes, innerBlocks } ) => createBlock( name, attributes, innerBlocks ); + +export default compose( [ + withSelect( ( select, { clientId } ) => ( { + block: select( 'core/block-editor' ).getBlock( clientId ), + } ) ), + withDispatch( ( dispatch, { block } ) => { + const { replaceBlock } = dispatch( 'core/block-editor' ); + + return { + convertToClassic() { + replaceBlock( block.clientId, blockToClassic( block ) ); + }, + convertToHTML() { + replaceBlock( block.clientId, blockToHTML( block ) ); + }, + convertToBlocks() { + replaceBlock( block.clientId, blockToBlocks( block ) ); + }, + attemptBlockRecovery() { + replaceBlock( block.clientId, recoverBlock( block ) ); + }, + }; + } ), +] )( BlockInvalidWarning ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.native.js b/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.native.js new file mode 100644 index 0000000000000..38a03da3cf5ee --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-invalid-warning.native.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Warning from '../warning'; + +export default function BlockInvalidWarning( { blockTitle, icon } ) { + const accessibilityLabel = sprintf( + /* translators: accessibility text for blocks with invalid content. %d: localized block title */ + __( '%s block. This block has invalid content' ), + blockTitle + ); + + return ( + <Warning + title={ blockTitle } + message={ __( 'Problem displaying block' ) } + icon={ icon } + accessible={ true } + accessibilityLabel={ accessibilityLabel } + /> + ); +} diff --git a/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.js b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.js new file mode 100644 index 0000000000000..f35a7add73641 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { ifViewportMatches } from '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import BlockMover from '../block-mover'; +import VisualEditorInserter from '../inserter'; + +function BlockMobileToolbar( { clientId } ) { + return ( + <div className="editor-block-list__block-mobile-toolbar block-editor-block-list__block-mobile-toolbar"> + <VisualEditorInserter /> + <BlockMover clientIds={ [ clientId ] } /> + </div> + ); +} + +export default ifViewportMatches( '< small' )( BlockMobileToolbar ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.js b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.js new file mode 100644 index 0000000000000..96395c41c835f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import { Keyboard, View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { ToolbarButton } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import styles from './block-mobile-toolbar.scss'; +import BlockMover from '../block-mover'; +import InspectorControls from '../inspector-controls'; + +const BlockMobileToolbar = ( { + clientId, + onDelete, + order, +} ) => ( + <View style={ styles.toolbar }> + <BlockMover clientIds={ [ clientId ] } /> + + <View style={ styles.spacer } /> + + <InspectorControls.Slot /> + + <ToolbarButton + title={ + sprintf( + /* translators: accessibility text. %s: current block position (number). */ + __( 'Remove block at row %s' ), + order + 1 + ) + } + onClick={ onDelete } + icon="trash" + extraProps={ { hint: __( 'Double tap to remove the block' ) } } + /> + </View> +); + +export default compose( + withSelect( ( select, { clientId } ) => { + const { + getBlockIndex, + } = select( 'core/block-editor' ); + + return { + order: getBlockIndex( clientId ), + }; + } ), + withDispatch( ( dispatch, { clientId, rootClientId } ) => { + const { removeBlock } = dispatch( 'core/block-editor' ); + return { + onDelete: () => { + Keyboard.dismiss(); + removeBlock( clientId, rootClientId ); + }, + }; + } ), +)( BlockMobileToolbar ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.scss b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.scss new file mode 100644 index 0000000000000..2633fc1f1c2fc --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block-mobile-toolbar.native.scss @@ -0,0 +1,11 @@ +.toolbar { + flex-direction: row; + height: 44px; + align-items: flex-start; + margin-left: 2px; + margin-right: 2px; +} + +.spacer { + flex-grow: 1; +} diff --git a/gb-src/packages/block-editor/src/components/block-list/block.js b/gb-src/packages/block-editor/src/components/block-list/block.js new file mode 100644 index 0000000000000..747d7618582be --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block.js @@ -0,0 +1,789 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { first, last } from 'lodash'; +import { animated } from 'react-spring/web.cjs'; + +/** + * WordPress dependencies + */ +import { useRef, useEffect, useLayoutEffect, useState, RawHTML } from '@wordpress/element'; +import { + focus, + isTextField, + placeCaretAtHorizontalEdge, + safeHTML, +} from '@wordpress/dom'; +import { BACKSPACE, DELETE, ENTER, ESCAPE } from '@wordpress/keycodes'; +import { + getBlockType, + getSaveContent, + isReusableBlock, + isUnmodifiedDefaultBlock, + getUnregisteredTypeHandlerName, +} from '@wordpress/blocks'; +import { KeyboardShortcuts, withFilters } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { + withDispatch, + withSelect, +} from '@wordpress/data'; +import { withViewportMatch } from '@wordpress/viewport'; +import { compose, pure, ifCondition } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockEdit from '../block-edit'; +import BlockMover from '../block-mover'; +import BlockDropZone from '../block-drop-zone'; +import BlockInvalidWarning from './block-invalid-warning'; +import BlockCrashWarning from './block-crash-warning'; +import BlockCrashBoundary from './block-crash-boundary'; +import BlockHtml from './block-html'; +import BlockBreadcrumb from './breadcrumb'; +import BlockContextualToolbar from './block-contextual-toolbar'; +import BlockMultiControls from './multi-controls'; +import BlockMobileToolbar from './block-mobile-toolbar'; +import BlockInsertionPoint from './insertion-point'; +import IgnoreNestedEvents from '../ignore-nested-events'; +import InserterWithShortcuts from '../inserter-with-shortcuts'; +import Inserter from '../inserter'; +import { isInsideRootBlock } from '../../utils/dom'; +import useMovingAnimation from './moving-animation'; + +/** + * Prevents default dragging behavior within a block to allow for multi- + * selection to take effect unhampered. + * + * @param {DragEvent} event Drag event. + */ +const preventDrag = ( event ) => { + event.preventDefault(); +}; + +function BlockListBlock( { + blockRef, + mode, + isFocusMode, + hasFixedToolbar, + isLocked, + clientId, + rootClientId, + isSelected, + isPartOfMultiSelection, + isFirstMultiSelected, + isTypingWithinBlock, + isCaretWithinFormattedText, + isEmptyDefaultBlock, + isMovable, + isParentOfSelectedBlock, + isDraggable, + isSelectionEnabled, + className, + name, + isValid, + isLast, + attributes, + initialPosition, + wrapperProps, + setAttributes, + onReplace, + onInsertBlocksAfter, + onMerge, + onSelect, + onRemove, + onInsertDefaultBlockAfter, + toggleSelection, + onShiftSelection, + onSelectionStart, + animateOnChange, + enableAnimation, + isNavigationMode, + enableNavigationMode, +} ) { + // Random state used to rerender the component if needed, ideally we don't need this + const [ , updateRerenderState ] = useState( {} ); + const rerender = () => updateRerenderState( {} ); + + // Reference of the wrapper + const wrapper = useRef( null ); + useEffect( () => { + blockRef( wrapper.current, clientId ); + }, [] ); + + // Reference to the block edit node + const blockNodeRef = useRef(); + + const breadcrumb = useRef(); + + // Keep track of touchstart to disable hover on iOS + const hadTouchStart = useRef( false ); + const onTouchStart = () => { + hadTouchStart.current = true; + }; + const onTouchStop = () => { + // Clear touchstart detection + // Browser will try to emulate mouse events also see https://www.html5rocks.com/en/mobile/touchandmouse/ + hadTouchStart.current = false; + }; + + // Handling isHovered + const [ isBlockHovered, setBlockHoveredState ] = useState( false ); + + /** + * Sets the block state as unhovered if currently hovering. There are cases + * where mouseleave may occur but the block is not hovered (multi-select), + * so to avoid unnecesary renders, the state is only set if hovered. + */ + const hideHoverEffects = () => { + if ( isBlockHovered ) { + setBlockHoveredState( false ); + } + }; + /** + * A mouseover event handler to apply hover effect when a pointer device is + * placed within the bounds of the block. The mouseover event is preferred + * over mouseenter because it may be the case that a previous mouseenter + * event was blocked from being handled by a IgnoreNestedEvents component, + * therefore transitioning out of a nested block to the bounds of the block + * would otherwise not trigger a hover effect. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Events/mouseenter + */ + const maybeHover = () => { + if ( + isBlockHovered || + isPartOfMultiSelection || + isSelected || + hadTouchStart.current + ) { + return; + } + setBlockHoveredState( true ); + }; + + // Set hover to false once we start typing or select the block. + useEffect( () => { + if ( isTypingWithinBlock || isSelected ) { + hideHoverEffects(); + } + } ); + + // Handling the dragging state + const [ isDragging, setBlockDraggingState ] = useState( false ); + const onDragStart = () => { + setBlockDraggingState( true ); + }; + const onDragEnd = () => { + setBlockDraggingState( false ); + }; + + // Handling the error state + const [ hasError, setErrorState ] = useState( false ); + const onBlockError = () => setErrorState( true ); + + // Handling of forceContextualToolbarFocus + const isForcingContextualToolbar = useRef( false ); + useEffect( () => { + if ( isForcingContextualToolbar.current ) { + // The forcing of contextual toolbar should only be true during one update, + // after the first update normal conditions should apply. + isForcingContextualToolbar.current = false; + } + } ); + const forceFocusedContextualToolbar = () => { + isForcingContextualToolbar.current = true; + // trigger a re-render + rerender(); + }; + + // Handing the focus of the block on creation and update + + /** + * When a block becomes selected, transition focus to an inner tabbable. + * + * @param {boolean} ignoreInnerBlocks Should not focus inner blocks. + */ + const focusTabbable = ( ignoreInnerBlocks ) => { + // Focus is captured by the wrapper node, so while focus transition + // should only consider tabbables within editable display, since it + // may be the wrapper itself or a side control which triggered the + // focus event, don't unnecessary transition to an inner tabbable. + if ( wrapper.current.contains( document.activeElement ) ) { + return; + } + + if ( isNavigationMode ) { + breadcrumb.current.focus(); + return; + } + + // Find all tabbables within node. + const textInputs = focus.tabbable + .find( blockNodeRef.current ) + .filter( isTextField ) + // Exclude inner blocks + .filter( ( node ) => ! ignoreInnerBlocks || isInsideRootBlock( blockNodeRef.current, node ) ); + + // If reversed (e.g. merge via backspace), use the last in the set of + // tabbables. + const isReverse = -1 === initialPosition; + const target = ( isReverse ? last : first )( textInputs ); + + if ( ! target ) { + wrapper.current.focus(); + return; + } + + placeCaretAtHorizontalEdge( target, isReverse ); + }; + + // Focus the selected block's wrapper or inner input on mount and update + const isMounting = useRef( true ); + useEffect( () => { + if ( isSelected ) { + focusTabbable( ! isMounting.current ); + } + isMounting.current = false; + }, [ isSelected ] ); + + // Focus the first multi selected block + useEffect( () => { + if ( isFirstMultiSelected ) { + wrapper.current.focus(); + } + }, [ isFirstMultiSelected ] ); + + // Block Reordering animation + const animationStyle = useMovingAnimation( wrapper, isSelected || isPartOfMultiSelection, isSelected || isFirstMultiSelected, enableAnimation, animateOnChange ); + + // Focus the breadcrumb if the wrapper is focused on navigation mode. + // Focus the first editable or the wrapper if edit mode. + useLayoutEffect( () => { + if ( isSelected ) { + if ( isNavigationMode ) { + breadcrumb.current.focus(); + } else { + focusTabbable( true ); + } + } + }, [ isSelected, isNavigationMode ] ); + + // Other event handlers + + /** + * Marks the block as selected when focused and not already selected. This + * specifically handles the case where block does not set focus on its own + * (via `setFocus`), typically if there is no focusable input in the block. + */ + const onFocus = () => { + if ( ! isSelected && ! isPartOfMultiSelection ) { + onSelect(); + } + }; + + /** + * Interprets keydown event intent to remove or insert after block if key + * event occurs on wrapper node. This can occur when the block has no text + * fields of its own, particularly after initial insertion, to allow for + * easy deletion and continuous writing flow to add additional content. + * + * @param {KeyboardEvent} event Keydown event. + */ + const onKeyDown = ( event ) => { + const { keyCode, target } = event; + + // ENTER/BACKSPACE Shortcuts are only available if the wrapper is focused + // and the block is not locked. + const canUseShortcuts = ( + isSelected && + ! isLocked && + ( target === wrapper.current || target === breadcrumb.current ) + ); + const isEditMode = ! isNavigationMode; + + switch ( keyCode ) { + case ENTER: + if ( canUseShortcuts && isEditMode ) { + // Insert default block after current block if enter and event + // not already handled by descendant. + onInsertDefaultBlockAfter(); + event.preventDefault(); + } + break; + case BACKSPACE: + case DELETE: + if ( canUseShortcuts ) { + // Remove block on backspace. + onRemove( clientId ); + event.preventDefault(); + } + break; + case ESCAPE: + if ( + isSelected && + isEditMode + ) { + enableNavigationMode(); + wrapper.current.focus(); + } + break; + } + }; + + /** + * Begins tracking cursor multi-selection when clicking down within block. + * + * @param {MouseEvent} event A mousedown event. + */ + const onPointerDown = ( event ) => { + // Not the main button. + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button + if ( event.button !== 0 ) { + return; + } + + if ( event.shiftKey ) { + if ( ! isSelected ) { + onShiftSelection(); + event.preventDefault(); + } + + // Avoid triggering multi-selection if we click toolbars/inspectors + // and all elements that are outside the Block Edit DOM tree. + } else if ( blockNodeRef.current.contains( event.target ) ) { + onSelectionStart( clientId ); + + // Allow user to escape out of a multi-selection to a singular + // selection of a block via click. This is handled here since + // onFocus excludes blocks involved in a multiselection, as + // focus can be incurred by starting a multiselection (focus + // moved to first block's multi-controls). + if ( isPartOfMultiSelection ) { + onSelect(); + } + } + }; + + const selectOnOpen = ( open ) => { + if ( open && ! isSelected ) { + onSelect(); + } + }; + + // Rendering the output + const isHovered = isBlockHovered && ! isPartOfMultiSelection; + const blockType = getBlockType( name ); + // translators: %s: Type of block (i.e. Text, Image etc) + const blockLabel = sprintf( __( 'Block: %s' ), blockType.title ); + // The block as rendered in the editor is composed of general block UI + // (mover, toolbar, wrapper) and the display of the block content. + + const isUnregisteredBlock = name === getUnregisteredTypeHandlerName(); + + // If the block is selected and we're typing the block should not appear. + // Empty paragraph blocks should always show up as unselected. + const showInserterShortcuts = ! isNavigationMode && ( isSelected || isHovered ) && isEmptyDefaultBlock && isValid; + const showEmptyBlockSideInserter = ! isNavigationMode && ( isSelected || isHovered || isLast ) && isEmptyDefaultBlock && isValid; + const shouldAppearSelected = + ! isFocusMode && + ! showEmptyBlockSideInserter && + isSelected && + ! isTypingWithinBlock; + const shouldAppearHovered = + ! isFocusMode && + ! hasFixedToolbar && + isHovered && + ! isEmptyDefaultBlock; + // We render block movers and block settings to keep them tabbale even if hidden + const shouldRenderMovers = + ! isNavigationMode && + isSelected && + ! showEmptyBlockSideInserter && + ! isPartOfMultiSelection && + ! isTypingWithinBlock; + const shouldShowBreadcrumb = + ( isSelected && isNavigationMode ) || + ( ! isNavigationMode && ! isFocusMode && isHovered && ! isEmptyDefaultBlock ); + const shouldShowContextualToolbar = + ! isNavigationMode && + ! hasFixedToolbar && + ! showEmptyBlockSideInserter && + ( + ( isSelected && ( ! isTypingWithinBlock || isCaretWithinFormattedText ) ) || + isFirstMultiSelected + ); + const shouldShowMobileToolbar = ! isNavigationMode && shouldAppearSelected; + + // Insertion point can only be made visible if the block is at the + // the extent of a multi-selection, or not in a multi-selection. + const shouldShowInsertionPoint = + ( isPartOfMultiSelection && isFirstMultiSelected ) || + ! isPartOfMultiSelection; + + // The wp-block className is important for editor styles. + // Generate the wrapper class names handling the different states of the block. + const wrapperClassName = classnames( + 'wp-block editor-block-list__block block-editor-block-list__block', + { + 'has-warning': ! isValid || !! hasError || isUnregisteredBlock, + 'is-selected': shouldAppearSelected, + 'is-navigate-mode': isNavigationMode, + 'is-multi-selected': isPartOfMultiSelection, + 'is-hovered': shouldAppearHovered, + 'is-reusable': isReusableBlock( blockType ), + 'is-dragging': isDragging, + 'is-typing': isTypingWithinBlock, + 'is-focused': isFocusMode && ( isSelected || isParentOfSelectedBlock ), + 'is-focus-mode': isFocusMode, + 'has-child-selected': isParentOfSelectedBlock, + }, + className + ); + + // Determine whether the block has props to apply to the wrapper. + if ( blockType.getEditWrapperProps ) { + wrapperProps = { + ...wrapperProps, + ...blockType.getEditWrapperProps( attributes ), + }; + } + const blockElementId = `block-${ clientId }`; + + // We wrap the BlockEdit component in a div that hides it when editing in + // HTML mode. This allows us to render all of the ancillary pieces + // (InspectorControls, etc.) which are inside `BlockEdit` but not + // `BlockHTML`, even in HTML mode. + let blockEdit = ( + <BlockEdit + name={ name } + isSelected={ isSelected } + attributes={ attributes } + setAttributes={ setAttributes } + insertBlocksAfter={ isLocked ? undefined : onInsertBlocksAfter } + onReplace={ isLocked ? undefined : onReplace } + mergeBlocks={ isLocked ? undefined : onMerge } + clientId={ clientId } + isSelectionEnabled={ isSelectionEnabled } + toggleSelection={ toggleSelection } + /> + ); + if ( mode !== 'visual' ) { + blockEdit = <div style={ { display: 'none' } }>{ blockEdit }</div>; + } + + return ( + <IgnoreNestedEvents + id={ blockElementId } + ref={ wrapper } + onMouseOver={ maybeHover } + onMouseOverHandled={ hideHoverEffects } + onMouseLeave={ hideHoverEffects } + className={ wrapperClassName } + data-type={ name } + onTouchStart={ onTouchStart } + onFocus={ onFocus } + onClick={ onTouchStop } + onKeyDown={ onKeyDown } + tabIndex="0" + aria-label={ blockLabel } + childHandledEvents={ [ 'onDragStart', 'onMouseDown' ] } + tagName={ animated.div } + { ...wrapperProps } + style={ + wrapperProps && wrapperProps.style ? + { + ...wrapperProps.style, + ...animationStyle, + } : + animationStyle + } + > + { shouldShowInsertionPoint && ( + <BlockInsertionPoint + clientId={ clientId } + rootClientId={ rootClientId } + /> + ) } + <BlockDropZone + clientId={ clientId } + rootClientId={ rootClientId } + /> + { isFirstMultiSelected && ( + <BlockMultiControls rootClientId={ rootClientId } /> + ) } + <div className="editor-block-list__block-edit block-editor-block-list__block-edit"> + { shouldRenderMovers && ( + <BlockMover + clientIds={ clientId } + blockElementId={ blockElementId } + isHidden={ ! isSelected } + isDraggable={ + isDraggable !== false && + ( ! isPartOfMultiSelection && isMovable ) + } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + /> + ) } + { shouldShowBreadcrumb && ( + <BlockBreadcrumb + clientId={ clientId } + ref={ breadcrumb } + /> + ) } + { ( shouldShowContextualToolbar || isForcingContextualToolbar.current ) && ( + <BlockContextualToolbar + // If the toolbar is being shown because of being forced + // it should focus the toolbar right after the mount. + focusOnMount={ isForcingContextualToolbar.current } + /> + ) } + { + ! isNavigationMode && + ! shouldShowContextualToolbar && + isSelected && + ! hasFixedToolbar && + ! isEmptyDefaultBlock && ( + <KeyboardShortcuts + bindGlobal + eventName="keydown" + shortcuts={ { + 'alt+f10': forceFocusedContextualToolbar, + } } + /> + ) + } + <IgnoreNestedEvents + ref={ blockNodeRef } + onDragStart={ preventDrag } + onMouseDown={ onPointerDown } + data-block={ clientId } + > + <BlockCrashBoundary onError={ onBlockError }> + { isValid && blockEdit } + { isValid && mode === 'html' && ( + <BlockHtml clientId={ clientId } /> + ) } + { ! isValid && [ + <BlockInvalidWarning + key="invalid-warning" + clientId={ clientId } + />, + <div key="invalid-preview"> + <RawHTML> + { safeHTML( + getSaveContent( blockType, attributes ) + ) } + </RawHTML> + </div>, + ] } + </BlockCrashBoundary> + { !! hasError && <BlockCrashWarning /> } + { shouldShowMobileToolbar && ( + <BlockMobileToolbar clientId={ clientId } /> + ) } + </IgnoreNestedEvents> + </div> + { showInserterShortcuts && ( + <div className="editor-block-list__side-inserter block-editor-block-list__side-inserter"> + <InserterWithShortcuts + clientId={ clientId } + rootClientId={ rootClientId } + onToggle={ selectOnOpen } + /> + </div> + ) } + { showEmptyBlockSideInserter && ( + <div className="editor-block-list__empty-block-inserter block-editor-block-list__empty-block-inserter"> + <Inserter + position="top right" + onToggle={ selectOnOpen } + rootClientId={ rootClientId } + clientId={ clientId } + /> + </div> + ) } + </IgnoreNestedEvents> + ); +} + +const applyWithSelect = withSelect( + ( select, { clientId, rootClientId, isLargeViewport } ) => { + const { + isBlockSelected, + isAncestorMultiSelected, + isBlockMultiSelected, + isFirstMultiSelectedBlock, + isTyping, + isCaretWithinFormattedText, + getBlockMode, + isSelectionEnabled, + getSelectedBlocksInitialCaretPosition, + getSettings, + hasSelectedInnerBlock, + getTemplateLock, + getBlockIndex, + getBlockOrder, + __unstableGetBlockWithoutInnerBlocks, + isNavigationMode, + } = select( 'core/block-editor' ); + const block = __unstableGetBlockWithoutInnerBlocks( clientId ); + const isSelected = isBlockSelected( clientId ); + const { hasFixedToolbar, focusMode, isRTL } = getSettings(); + const templateLock = getTemplateLock( rootClientId ); + const isParentOfSelectedBlock = hasSelectedInnerBlock( clientId, true ); + const index = getBlockIndex( clientId, rootClientId ); + const blockOrder = getBlockOrder( rootClientId ); + + // The fallback to `{}` is a temporary fix. + // This function should never be called when a block is not present in the state. + // It happens now because the order in withSelect rendering is not correct. + const { name, attributes, isValid } = block || {}; + + return { + isPartOfMultiSelection: + isBlockMultiSelected( clientId ) || isAncestorMultiSelected( clientId ), + isFirstMultiSelected: isFirstMultiSelectedBlock( clientId ), + // We only care about this prop when the block is selected + // Thus to avoid unnecessary rerenders we avoid updating the prop if the block is not selected. + isTypingWithinBlock: + ( isSelected || isParentOfSelectedBlock ) && isTyping(), + isCaretWithinFormattedText: isCaretWithinFormattedText(), + mode: getBlockMode( clientId ), + isSelectionEnabled: isSelectionEnabled(), + initialPosition: isSelected ? getSelectedBlocksInitialCaretPosition() : null, + isEmptyDefaultBlock: + name && isUnmodifiedDefaultBlock( { name, attributes } ), + isMovable: 'all' !== templateLock, + isLocked: !! templateLock, + isFocusMode: focusMode && isLargeViewport, + hasFixedToolbar: hasFixedToolbar && isLargeViewport, + isLast: index === blockOrder.length - 1, + isNavigationMode: isNavigationMode(), + isRTL, + + // Users of the editor.BlockListBlock filter used to be able to access the block prop + // Ideally these blocks would rely on the clientId prop only. + // This is kept for backward compatibility reasons. + block, + + name, + attributes, + isValid, + isSelected, + isParentOfSelectedBlock, + }; + } +); + +const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { + const { + updateBlockAttributes, + selectBlock, + multiSelect, + insertBlocks, + insertDefaultBlock, + removeBlock, + mergeBlocks, + replaceBlocks, + toggleSelection, + setNavigationMode, + __unstableMarkLastChangeAsPersistent, + } = dispatch( 'core/block-editor' ); + + return { + setAttributes( newAttributes ) { + const { clientId } = ownProps; + updateBlockAttributes( clientId, newAttributes ); + }, + onSelect( clientId = ownProps.clientId, initialPosition ) { + selectBlock( clientId, initialPosition ); + }, + onInsertBlocks( blocks, index ) { + const { rootClientId } = ownProps; + insertBlocks( blocks, index, rootClientId ); + }, + onInsertDefaultBlockAfter() { + const { clientId, rootClientId } = ownProps; + const { + getBlockIndex, + } = select( 'core/block-editor' ); + const index = getBlockIndex( clientId, rootClientId ); + insertDefaultBlock( {}, rootClientId, index + 1 ); + }, + onInsertBlocksAfter( blocks ) { + const { clientId, rootClientId } = ownProps; + const { + getBlockIndex, + } = select( 'core/block-editor' ); + const index = getBlockIndex( clientId, rootClientId ); + insertBlocks( blocks, index + 1, rootClientId ); + }, + onRemove( clientId ) { + removeBlock( clientId ); + }, + onMerge( forward ) { + const { clientId } = ownProps; + const { + getPreviousBlockClientId, + getNextBlockClientId, + } = select( 'core/block-editor' ); + + if ( forward ) { + const nextBlockClientId = getNextBlockClientId( clientId ); + if ( nextBlockClientId ) { + mergeBlocks( clientId, nextBlockClientId ); + } + } else { + const previousBlockClientId = getPreviousBlockClientId( clientId ); + if ( previousBlockClientId ) { + mergeBlocks( previousBlockClientId, clientId ); + } + } + }, + onReplace( blocks, indexToSelect ) { + if ( + blocks.length && + ! isUnmodifiedDefaultBlock( blocks[ blocks.length - 1 ] ) + ) { + __unstableMarkLastChangeAsPersistent(); + } + replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect ); + }, + onShiftSelection() { + if ( ! ownProps.isSelectionEnabled ) { + return; + } + + const { + getBlockSelectionStart, + } = select( 'core/block-editor' ); + + if ( getBlockSelectionStart() ) { + multiSelect( getBlockSelectionStart(), ownProps.clientId ); + } else { + selectBlock( ownProps.clientId ); + } + }, + toggleSelection( selectionEnabled ) { + toggleSelection( selectionEnabled ); + }, + enableNavigationMode() { + setNavigationMode( true ); + }, + }; +} ); + +export default compose( + pure, + withViewportMatch( { isLargeViewport: 'medium' } ), + applyWithSelect, + applyWithDispatch, + // block is sometimes not mounted at the right time, causing it be undefined + // see issue for more info https://github.com/WordPress/gutenberg/issues/17013 + ifCondition( ( { block } ) => !! block ), + withFilters( 'editor.BlockListBlock' ) +)( BlockListBlock ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block.native.js b/gb-src/packages/block-editor/src/components/block-list/block.native.js new file mode 100644 index 0000000000000..b87f95445e11f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block.native.js @@ -0,0 +1,220 @@ +/** + * External dependencies + */ +import { + View, + Text, + TouchableWithoutFeedback, +} from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { getBlockType } from '@wordpress/blocks'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import styles from './block.scss'; +import BlockEdit from '../block-edit'; +import BlockInvalidWarning from './block-invalid-warning'; +import BlockMobileToolbar from './block-mobile-toolbar'; + +class BlockListBlock extends Component { + constructor() { + super( ...arguments ); + + this.insertBlocksAfter = this.insertBlocksAfter.bind( this ); + this.onFocus = this.onFocus.bind( this ); + + this.state = { + isFullyBordered: false, + }; + } + + onFocus() { + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + } + + insertBlocksAfter( blocks ) { + this.props.onInsertBlocks( blocks, this.props.order + 1 ); + + if ( blocks[ 0 ] ) { + // focus on the first block inserted + this.props.onSelect( blocks[ 0 ].clientId ); + } + } + + getBlockForType() { + return ( + <BlockEdit + name={ this.props.name } + isSelected={ this.props.isSelected } + attributes={ this.props.attributes } + setAttributes={ this.props.onChange } + onFocus={ this.onFocus } + onReplace={ this.props.onReplace } + insertBlocksAfter={ this.insertBlocksAfter } + mergeBlocks={ this.props.mergeBlocks } + onCaretVerticalPositionChange={ this.props.onCaretVerticalPositionChange } + clientId={ this.props.clientId } + /> + ); + } + + renderBlockTitle() { + return ( + <View style={ styles.blockTitle }> + <Text>BlockType: { this.props.name }</Text> + </View> + ); + } + + getAccessibilityLabel() { + const { attributes, name, order, title, getAccessibilityLabelExtra } = this.props; + + let blockName = ''; + + if ( name === 'core/missing' ) { // is the block unrecognized? + blockName = title; + } else { + blockName = sprintf( + /* translators: accessibility text. %s: block name. */ + __( '%s Block' ), + title, //already localized + ); + } + + blockName += '. ' + sprintf( __( 'Row %d.' ), order + 1 ); + + if ( getAccessibilityLabelExtra ) { + const blockAccessibilityLabel = getAccessibilityLabelExtra( attributes ); + blockName += blockAccessibilityLabel ? ' ' + blockAccessibilityLabel : ''; + } + + return blockName; + } + + render() { + const { + borderStyle, + clientId, + focusedBorderColor, + icon, + isSelected, + isValid, + showTitle, + title, + } = this.props; + + const borderColor = isSelected ? focusedBorderColor : 'transparent'; + + const accessibilityLabel = this.getAccessibilityLabel(); + + return ( + <TouchableWithoutFeedback + onPress={ this.onFocus } + accessible={ ! isSelected } + accessibilityRole={ 'button' } + > + <View style={ [ styles.blockHolder, borderStyle, { borderColor } ] }> + { showTitle && this.renderBlockTitle() } + <View + accessibilityLabel={ accessibilityLabel } + style={ [ ! isSelected && styles.blockContainer, isSelected && styles.blockContainerFocused ] } + > + { isValid && this.getBlockForType() } + { ! isValid && + <BlockInvalidWarning blockTitle={ title } icon={ icon } /> + } + </View> + { isSelected && <BlockMobileToolbar clientId={ clientId } /> } + </View> + </TouchableWithoutFeedback> + ); + } +} + +export default compose( [ + withSelect( ( select, { clientId, rootClientId } ) => { + const { + getBlockIndex, + getBlocks, + isBlockSelected, + __unstableGetBlockWithoutInnerBlocks, + } = select( 'core/block-editor' ); + const order = getBlockIndex( clientId, rootClientId ); + const isSelected = isBlockSelected( clientId ); + const isFirstBlock = order === 0; + const isLastBlock = order === getBlocks().length - 1; + const block = __unstableGetBlockWithoutInnerBlocks( clientId ); + const { name, attributes, isValid } = block || {}; + const blockType = getBlockType( name || 'core/missing' ); + const title = blockType.title; + const icon = blockType.icon; + const getAccessibilityLabelExtra = blockType.__experimentalGetAccessibilityLabel; + + return { + icon, + name: name || 'core/missing', + order, + title, + attributes, + blockType, + isFirstBlock, + isLastBlock, + isSelected, + isValid, + getAccessibilityLabelExtra, + }; + } ), + withDispatch( ( dispatch, ownProps, { select } ) => { + const { + insertBlocks, + mergeBlocks, + replaceBlocks, + selectBlock, + updateBlockAttributes, + } = dispatch( 'core/block-editor' ); + + return { + mergeBlocks( forward ) { + const { clientId } = ownProps; + const { + getPreviousBlockClientId, + getNextBlockClientId, + } = select( 'core/block-editor' ); + + if ( forward ) { + const nextBlockClientId = getNextBlockClientId( clientId ); + if ( nextBlockClientId ) { + mergeBlocks( clientId, nextBlockClientId ); + } + } else { + const previousBlockClientId = getPreviousBlockClientId( clientId ); + if ( previousBlockClientId ) { + mergeBlocks( previousBlockClientId, clientId ); + } + } + }, + onInsertBlocks( blocks, index ) { + insertBlocks( blocks, index, ownProps.rootClientId ); + }, + onSelect( clientId = ownProps.clientId, initialPosition ) { + selectBlock( clientId, initialPosition ); + }, + onChange: ( attributes ) => { + updateBlockAttributes( ownProps.clientId, attributes ); + }, + onReplace( blocks, indexToSelect ) { + replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect ); + }, + }; + } ), +] )( BlockListBlock ); diff --git a/gb-src/packages/block-editor/src/components/block-list/block.native.scss b/gb-src/packages/block-editor/src/components/block-list/block.native.scss new file mode 100644 index 0000000000000..b997cddc72307 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/block.native.scss @@ -0,0 +1,38 @@ +.blockHolder { + flex: 1 1 auto; +} + +.blockContainer { + background-color: $white; + padding-left: 16px; + padding-right: 16px; + padding-top: 12px; + padding-bottom: 12px; +} + +.blockContainerFocused { + background-color: $white; + padding-left: 16px; + padding-right: 16px; + padding-top: 12px; + padding-bottom: 0; // will be flushed into inline toolbar height +} + +.blockTitle { + background-color: $gray; + padding-left: 8px; + padding-top: 4px; + padding-bottom: 4px; +} + +.aztec_container { + flex: 1; +} + +.blockCode { + font-family: $default-monospace-font; +} + +.blockText { + min-height: 50px; +} diff --git a/gb-src/packages/block-editor/src/components/block-list/breadcrumb.js b/gb-src/packages/block-editor/src/components/block-list/breadcrumb.js new file mode 100644 index 0000000000000..81f05b40bf708 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/breadcrumb.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { Toolbar, Button } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BlockTitle from '../block-title'; + +/** + * Block breadcrumb component, displaying the label of the block. If the block + * descends from a root block, a button is displayed enabling the user to select + * the root block. + * + * @param {string} props.clientId Client ID of block. + * @return {WPElement} Block Breadcrumb. + */ +const BlockBreadcrumb = forwardRef( ( { clientId }, ref ) => { + const { setNavigationMode } = useDispatch( 'core/block-editor' ); + const { rootClientId } = useSelect( ( select ) => { + return { + rootClientId: select( 'core/block-editor' ).getBlockRootClientId( clientId ), + }; + } ); + + return ( + <div className="editor-block-list__breadcrumb block-editor-block-list__breadcrumb"> + <Toolbar> + { rootClientId && ( + <> + <BlockTitle clientId={ rootClientId } /> + <span className="editor-block-list__descendant-arrow block-editor-block-list__descendant-arrow" /> + </> + ) } + <Button ref={ ref } onClick={ () => setNavigationMode( false ) }> + <BlockTitle clientId={ clientId } /> + </Button> + </Toolbar> + </div> + ); +} ); + +export default BlockBreadcrumb; diff --git a/gb-src/packages/block-editor/src/components/block-list/index.js b/gb-src/packages/block-editor/src/components/block-list/index.js new file mode 100644 index 0000000000000..25386d5039c12 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/index.js @@ -0,0 +1,299 @@ +/** + * External dependencies + */ +import { + findLast, + invert, + mapValues, + sortBy, + throttle, +} from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + withSelect, + withDispatch, + __experimentalAsyncModeProvider as AsyncModeProvider, +} from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockAsyncModeProvider from './block-async-mode-provider'; +import BlockListBlock from './block'; +import BlockListAppender from '../block-list-appender'; +import { getBlockDOMNode } from '../../utils/dom'; + +/** + * If the block count exceeds the threshold, we disable the reordering animation + * to avoid laginess. + */ +const BLOCK_ANIMATION_THRESHOLD = 200; + +const forceSyncUpdates = ( WrappedComponent ) => ( props ) => { + return ( + <AsyncModeProvider value={ false }> + <WrappedComponent { ...props } /> + </AsyncModeProvider> + ); +}; + +class BlockList extends Component { + constructor( props ) { + super( props ); + + this.onSelectionStart = this.onSelectionStart.bind( this ); + this.onSelectionEnd = this.onSelectionEnd.bind( this ); + this.setBlockRef = this.setBlockRef.bind( this ); + this.setLastClientY = this.setLastClientY.bind( this ); + this.onPointerMove = throttle( this.onPointerMove.bind( this ), 100 ); + // Browser does not fire `*move` event when the pointer position changes + // relative to the document, so fire it with the last known position. + this.onScroll = () => this.onPointerMove( { clientY: this.lastClientY } ); + + this.lastClientY = 0; + this.nodes = {}; + } + + componentDidMount() { + window.addEventListener( 'mousemove', this.setLastClientY ); + } + + componentWillUnmount() { + window.removeEventListener( 'mousemove', this.setLastClientY ); + } + + setLastClientY( { clientY } ) { + this.lastClientY = clientY; + } + + setBlockRef( node, clientId ) { + if ( node === null ) { + delete this.nodes[ clientId ]; + } else { + this.nodes = { + ...this.nodes, + [ clientId ]: node, + }; + } + } + + /** + * Handles a pointer move event to update the extent of the current cursor + * multi-selection. + * + * @param {MouseEvent} event A mousemove event object. + */ + onPointerMove( { clientY } ) { + // We don't start multi-selection until the mouse starts moving, so as + // to avoid dispatching multi-selection actions on an in-place click. + if ( ! this.props.isMultiSelecting ) { + this.props.onStartMultiSelect(); + } + + const blockContentBoundaries = getBlockDOMNode( this.selectionAtStart ).getBoundingClientRect(); + + // prevent multi-selection from triggering when the selected block is a float + // and the cursor is still between the top and the bottom of the block. + if ( clientY >= blockContentBoundaries.top && clientY <= blockContentBoundaries.bottom ) { + return; + } + + const y = clientY - blockContentBoundaries.top; + const key = findLast( this.coordMapKeys, ( coordY ) => coordY < y ); + + this.onSelectionChange( this.coordMap[ key ] ); + } + + /** + * Binds event handlers to the document for tracking a pending multi-select + * in response to a mousedown event occurring in a rendered block. + * + * @param {string} clientId Client ID of block where mousedown occurred. + */ + onSelectionStart( clientId ) { + if ( ! this.props.isSelectionEnabled ) { + return; + } + + const boundaries = this.nodes[ clientId ].getBoundingClientRect(); + + // Create a clientId to Y coördinate map. + const clientIdToCoordMap = mapValues( this.nodes, ( node ) => + node.getBoundingClientRect().top - boundaries.top ); + + // Cache a Y coördinate to clientId map for use in `onPointerMove`. + this.coordMap = invert( clientIdToCoordMap ); + // Cache an array of the Y coördinates for use in `onPointerMove`. + // Sort the coördinates, as `this.nodes` will not necessarily reflect + // the current block sequence. + this.coordMapKeys = sortBy( Object.values( clientIdToCoordMap ) ); + this.selectionAtStart = clientId; + + window.addEventListener( 'mousemove', this.onPointerMove ); + // Capture scroll on all elements. + window.addEventListener( 'scroll', this.onScroll, true ); + window.addEventListener( 'mouseup', this.onSelectionEnd ); + } + + /** + * Handles multi-selection changes in response to pointer move. + * + * @param {string} clientId Client ID of block under cursor in multi-select + * drag. + */ + onSelectionChange( clientId ) { + const { onMultiSelect, selectionStart, selectionEnd } = this.props; + const { selectionAtStart } = this; + const isAtStart = selectionAtStart === clientId; + + if ( ! selectionAtStart || ! this.props.isSelectionEnabled ) { + return; + } + + // If multi-selecting and cursor extent returns to the start of + // selection, cancel multi-select. + if ( isAtStart && selectionStart ) { + onMultiSelect( null, null ); + } + + // Expand multi-selection to block under cursor. + if ( ! isAtStart && selectionEnd !== clientId ) { + onMultiSelect( selectionAtStart, clientId ); + } + } + + /** + * Handles a mouseup event to end the current cursor multi-selection. + */ + onSelectionEnd() { + // Cancel throttled calls. + this.onPointerMove.cancel(); + + delete this.coordMap; + delete this.coordMapKeys; + delete this.selectionAtStart; + + window.removeEventListener( 'mousemove', this.onPointerMove ); + window.removeEventListener( 'scroll', this.onScroll, true ); + window.removeEventListener( 'mouseup', this.onSelectionEnd ); + + // We may or may not be in a multi-selection when mouseup occurs (e.g. + // an in-place mouse click), so only trigger stop if multi-selecting. + if ( this.props.isMultiSelecting ) { + this.props.onStopMultiSelect(); + } + } + + render() { + const { + className, + blockClientIds, + rootClientId, + isDraggable, + selectedBlockClientId, + multiSelectedBlockClientIds, + hasMultiSelection, + renderAppender, + enableAnimation, + } = this.props; + + return ( + <div className={ + classnames( + 'editor-block-list__layout block-editor-block-list__layout', + className + ) + }> + { blockClientIds.map( ( clientId, index ) => { + const isBlockInSelection = hasMultiSelection ? + multiSelectedBlockClientIds.includes( clientId ) : + selectedBlockClientId === clientId; + + return ( + <BlockAsyncModeProvider + key={ 'block-' + clientId } + clientId={ clientId } + isBlockInSelection={ isBlockInSelection } + > + <BlockListBlock + rootClientId={ rootClientId } + clientId={ clientId } + blockRef={ this.setBlockRef } + onSelectionStart={ this.onSelectionStart } + isDraggable={ isDraggable } + + // This prop is explicitely computed and passed down + // to avoid being impacted by the async mode + // otherwise there might be a small delay to trigger the animation. + animateOnChange={ index } + enableAnimation={ enableAnimation } + /> + </BlockAsyncModeProvider> + ); + } ) } + + <BlockListAppender + rootClientId={ rootClientId } + renderAppender={ renderAppender } + /> + </div> + ); + } +} + +export default compose( [ + // This component needs to always be synchronous + // as it's the one changing the async mode + // depending on the block selection. + forceSyncUpdates, + withSelect( ( select, ownProps ) => { + const { + getBlockOrder, + isSelectionEnabled, + isMultiSelecting, + getMultiSelectedBlocksStartClientId, + getMultiSelectedBlocksEndClientId, + getSelectedBlockClientId, + getMultiSelectedBlockClientIds, + hasMultiSelection, + getGlobalBlockCount, + isTyping, + } = select( 'core/block-editor' ); + + const { rootClientId } = ownProps; + + return { + blockClientIds: getBlockOrder( rootClientId ), + selectionStart: getMultiSelectedBlocksStartClientId(), + selectionEnd: getMultiSelectedBlocksEndClientId(), + isSelectionEnabled: isSelectionEnabled(), + isMultiSelecting: isMultiSelecting(), + selectedBlockClientId: getSelectedBlockClientId(), + multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), + hasMultiSelection: hasMultiSelection(), + enableAnimation: ( + ! isTyping() && + getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD + ), + }; + } ), + withDispatch( ( dispatch ) => { + const { + startMultiSelect, + stopMultiSelect, + multiSelect, + } = dispatch( 'core/block-editor' ); + + return { + onStartMultiSelect: startMultiSelect, + onStopMultiSelect: stopMultiSelect, + onMultiSelect: multiSelect, + }; + } ), +] )( BlockList ); diff --git a/gb-src/packages/block-editor/src/components/block-list/index.native.js b/gb-src/packages/block-editor/src/components/block-list/index.native.js new file mode 100644 index 0000000000000..d25dcc85a9075 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/index.native.js @@ -0,0 +1,208 @@ +/** + * External dependencies + */ +import { identity } from 'lodash'; +import { Text, View, Platform, TouchableWithoutFeedback } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { createBlock, isUnmodifiedDefaultBlock } from '@wordpress/blocks'; +import { KeyboardAwareFlatList, ReadableContentView } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; +import BlockListBlock from './block'; +import DefaultBlockAppender from '../default-block-appender'; + +const innerToolbarHeight = 44; + +export class BlockList extends Component { + constructor() { + super( ...arguments ); + + this.renderItem = this.renderItem.bind( this ); + this.renderAddBlockSeparator = this.renderAddBlockSeparator.bind( this ); + this.renderBlockListFooter = this.renderBlockListFooter.bind( this ); + this.renderDefaultBlockAppender = this.renderDefaultBlockAppender.bind( this ); + this.onCaretVerticalPositionChange = this.onCaretVerticalPositionChange.bind( this ); + this.scrollViewInnerRef = this.scrollViewInnerRef.bind( this ); + this.addBlockToEndOfPost = this.addBlockToEndOfPost.bind( this ); + this.shouldFlatListPreventAutomaticScroll = this.shouldFlatListPreventAutomaticScroll.bind( this ); + } + + addBlockToEndOfPost( newBlock ) { + this.props.insertBlock( newBlock, this.props.blockCount ); + } + + blockHolderBorderStyle() { + return this.props.isFullyBordered ? styles.blockHolderFullBordered : styles.blockHolderSemiBordered; + } + + onCaretVerticalPositionChange( targetId, caretY, previousCaretY ) { + KeyboardAwareFlatList.handleCaretVerticalPositionChange( this.scrollViewRef, targetId, caretY, previousCaretY ); + } + + scrollViewInnerRef( ref ) { + this.scrollViewRef = ref; + } + + shouldFlatListPreventAutomaticScroll() { + return this.props.isBlockInsertionPointVisible; + } + + renderDefaultBlockAppender() { + return ( + <ReadableContentView> + <DefaultBlockAppender + rootClientId={ this.props.rootClientId } + containerStyle={ [ + styles.blockContainerFocused, + this.blockHolderBorderStyle(), + { borderColor: 'transparent' }, + ] } + /> + </ReadableContentView> + ); + } + + render() { + return ( + <View + style={ { flex: 1 } } + onAccessibilityEscape={ this.props.clearSelectedBlock } + > + <KeyboardAwareFlatList + { ...( Platform.OS === 'android' ? { removeClippedSubviews: false } : {} ) } // Disable clipping on Android to fix focus losing. See https://github.com/wordpress-mobile/gutenberg-mobile/pull/741#issuecomment-472746541 + accessibilityLabel="block-list" + innerRef={ this.scrollViewInnerRef } + extraScrollHeight={ innerToolbarHeight + 10 } + keyboardShouldPersistTaps="always" + style={ styles.list } + data={ this.props.blockClientIds } + extraData={ [ this.props.isFullyBordered ] } + keyExtractor={ identity } + renderItem={ this.renderItem } + shouldPreventAutomaticScroll={ this.shouldFlatListPreventAutomaticScroll } + title={ this.props.title } + ListHeaderComponent={ this.props.header } + ListEmptyComponent={ this.renderDefaultBlockAppender } + ListFooterComponent={ this.renderBlockListFooter } + /> + </View> + ); + } + + isReplaceable( block ) { + if ( ! block ) { + return false; + } + return isUnmodifiedDefaultBlock( block ); + } + + renderItem( { item: clientId, index } ) { + const { shouldShowBlockAtIndex, shouldShowInsertionPoint } = this.props; + return ( + <ReadableContentView> + { shouldShowInsertionPoint( clientId ) && this.renderAddBlockSeparator() } + { shouldShowBlockAtIndex( index ) && ( + <BlockListBlock + key={ clientId } + showTitle={ false } + clientId={ clientId } + rootClientId={ this.props.rootClientId } + onCaretVerticalPositionChange={ this.onCaretVerticalPositionChange } + borderStyle={ this.blockHolderBorderStyle() } + focusedBorderColor={ styles.blockHolderFocused.borderColor } + /> ) } + </ReadableContentView> + ); + } + + renderAddBlockSeparator() { + return ( + <View style={ styles.containerStyleAddHere } > + <View style={ styles.lineStyleAddHere }></View> + <Text style={ styles.labelStyleAddHere } >{ __( 'ADD BLOCK HERE' ) }</Text> + <View style={ styles.lineStyleAddHere }></View> + </View> + ); + } + + renderBlockListFooter() { + const paragraphBlock = createBlock( 'core/paragraph' ); + return ( + <TouchableWithoutFeedback onPress={ () => { + this.addBlockToEndOfPost( paragraphBlock ); + } } > + <View style={ styles.blockListFooter } /> + </TouchableWithoutFeedback> + ); + } +} + +export default compose( [ + withSelect( ( select, { rootClientId } ) => { + const { + getBlockCount, + getBlockIndex, + getBlockOrder, + getSelectedBlockClientId, + getBlockInsertionPoint, + isBlockInsertionPointVisible, + } = select( 'core/block-editor' ); + + const selectedBlockClientId = getSelectedBlockClientId(); + const blockClientIds = getBlockOrder( rootClientId ); + const insertionPoint = getBlockInsertionPoint(); + const blockInsertionPointIsVisible = isBlockInsertionPointVisible(); + const shouldShowInsertionPoint = ( clientId ) => { + return ( + blockInsertionPointIsVisible && + insertionPoint.rootClientId === rootClientId && + blockClientIds[ insertionPoint.index ] === clientId + ); + }; + + const selectedBlockIndex = getBlockIndex( selectedBlockClientId ); + const shouldShowBlockAtIndex = ( index ) => { + const shouldHideBlockAtIndex = ( + blockInsertionPointIsVisible && + // if `index` === `insertionPoint.index`, then block is replaceable + index === insertionPoint.index && + // only hide selected block + index === selectedBlockIndex + ); + return ! shouldHideBlockAtIndex; + }; + + return { + blockClientIds, + blockCount: getBlockCount( rootClientId ), + isBlockInsertionPointVisible: isBlockInsertionPointVisible(), + shouldShowBlockAtIndex, + shouldShowInsertionPoint, + selectedBlockClientId, + }; + } ), + withDispatch( ( dispatch ) => { + const { + insertBlock, + replaceBlock, + clearSelectedBlock, + } = dispatch( 'core/block-editor' ); + + return { + clearSelectedBlock, + insertBlock, + replaceBlock, + }; + } ), +] )( BlockList ); + diff --git a/gb-src/packages/block-editor/src/components/block-list/insertion-point.js b/gb-src/packages/block-editor/src/components/block-list/insertion-point.js new file mode 100644 index 0000000000000..daa1a0f408f4e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/insertion-point.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import Inserter from '../inserter'; + +class BlockInsertionPoint extends Component { + constructor() { + super( ...arguments ); + this.state = { + isInserterFocused: false, + }; + + this.onBlurInserter = this.onBlurInserter.bind( this ); + this.onFocusInserter = this.onFocusInserter.bind( this ); + } + + onFocusInserter( event ) { + // Stop propagation of the focus event to avoid selecting the current + // block while inserting a new block, as it is not relevant to sibling + // insertion and conflicts with contextual toolbar placement. + event.stopPropagation(); + + this.setState( { + isInserterFocused: true, + } ); + } + + onBlurInserter() { + this.setState( { + isInserterFocused: false, + } ); + } + + render() { + const { isInserterFocused } = this.state; + const { + showInsertionPoint, + rootClientId, + clientId, + } = this.props; + + return ( + <div className="editor-block-list__insertion-point block-editor-block-list__insertion-point"> + { showInsertionPoint && ( + <div className="editor-block-list__insertion-point-indicator block-editor-block-list__insertion-point-indicator" /> + ) } + <div + onFocus={ this.onFocusInserter } + onBlur={ this.onBlurInserter } + // While ideally it would be enough to capture the + // bubbling focus event from the Inserter, due to the + // characteristics of click focusing of `button`s in + // Firefox and Safari, it is not reliable. + // + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + tabIndex={ -1 } + className={ + classnames( 'editor-block-list__insertion-point-inserter block-editor-block-list__insertion-point-inserter', { + 'is-visible': isInserterFocused, + } ) + } + > + <Inserter + rootClientId={ rootClientId } + clientId={ clientId } + /> + </div> + </div> + ); + } +} +export default withSelect( ( select, { clientId, rootClientId } ) => { + const { + getBlockIndex, + getBlockInsertionPoint, + isBlockInsertionPointVisible, + } = select( 'core/block-editor' ); + const blockIndex = getBlockIndex( clientId, rootClientId ); + const insertionPoint = getBlockInsertionPoint(); + const showInsertionPoint = ( + isBlockInsertionPointVisible() && + insertionPoint.index === blockIndex && + insertionPoint.rootClientId === rootClientId + ); + + return { showInsertionPoint }; +} )( BlockInsertionPoint ); diff --git a/gb-src/packages/block-editor/src/components/block-list/moving-animation.js b/gb-src/packages/block-editor/src/components/block-list/moving-animation.js new file mode 100644 index 0000000000000..afa36bb40304d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/moving-animation.js @@ -0,0 +1,133 @@ +/** + * External dependencies + */ +import { useSpring, interpolate } from 'react-spring/web.cjs'; + +/** + * WordPress dependencies + */ +import { useState, useLayoutEffect, useReducer, useMemo } from '@wordpress/element'; +import { useReducedMotion } from '@wordpress/compose'; +import { getScrollContainer } from '@wordpress/dom'; + +/** + * Simple reducer used to increment a counter. + * + * @param {number} state Previous counter value. + * @return {number} New state value. + */ +const counterReducer = ( state ) => state + 1; + +const getAbsolutePosition = ( element ) => { + return { + top: element.offsetTop, + left: element.offsetLeft, + }; +}; + +/** + * Hook used to compute the styles required to move a div into a new position. + * + * The way this animation works is the following: + * - It first renders the element as if there was no animation. + * - It takes a snapshot of the position of the block to use it + * as a destination point for the animation. + * - It restores the element to the previous position using a CSS transform + * - It uses the "resetAnimation" flag to reset the animation + * from the beginning in order to animate to the new destination point. + * + * @param {Object} ref Reference to the element to animate. + * @param {boolean} isSelected Whether it's the current block or not. + * @param {boolean} adjustScrolling Adjust the scroll position to the current block. + * @param {boolean} enableAnimation Enable/Disable animation. + * @param {*} triggerAnimationOnChange Variable used to trigger the animation if it changes. + * + * @return {Object} Style object. + */ +function useMovingAnimation( ref, isSelected, adjustScrolling, enableAnimation, triggerAnimationOnChange ) { + const prefersReducedMotion = useReducedMotion() || ! enableAnimation; + const [ triggeredAnimation, triggerAnimation ] = useReducer( counterReducer, 0 ); + const [ finishedAnimation, endAnimation ] = useReducer( counterReducer, 0 ); + const [ transform, setTransform ] = useState( { x: 0, y: 0, scrollTop: 0 } ); + + const previous = ref.current ? getAbsolutePosition( ref.current ) : null; + const scrollContainer = useMemo( () => { + if ( ! adjustScrolling ) { + return false; + } + return getScrollContainer( ref.current ); + }, [ adjustScrolling ] ); + + useLayoutEffect( () => { + if ( triggeredAnimation ) { + endAnimation(); + } + }, [ triggeredAnimation ] ); + useLayoutEffect( () => { + if ( prefersReducedMotion ) { + if ( adjustScrolling && scrollContainer ) { + // if the animation is disabled and the scroll needs to be adjusted, + // just move directly to the final scroll position + ref.current.style.transform = 'none'; + const destination = getAbsolutePosition( ref.current ); + scrollContainer.scrollTop = scrollContainer.scrollTop - previous.top + destination.top; + } + + return; + } + ref.current.style.transform = 'none'; + const destination = getAbsolutePosition( ref.current ); + const newTransform = { + x: previous ? previous.left - destination.left : 0, + y: previous ? previous.top - destination.top : 0, + scrollTop: previous && scrollContainer ? scrollContainer.scrollTop - previous.top + destination.top : 0, + }; + ref.current.style.transform = newTransform.x === 0 && newTransform.y === 0 ? + undefined : + `translate3d(${ newTransform.x }px,${ newTransform.y }px,0)`; + triggerAnimation(); + setTransform( newTransform ); + }, [ triggerAnimationOnChange ] ); + + const animationProps = useSpring( { + from: { + x: transform.x, + y: transform.y, + }, + to: { + x: 0, + y: 0, + }, + reset: triggeredAnimation !== finishedAnimation, + config: { mass: 5, tension: 2000, friction: 200 }, + immediate: prefersReducedMotion, + onFrame: ( props ) => { + if ( adjustScrolling && scrollContainer && ! prefersReducedMotion && props.y ) { + scrollContainer.scrollTop = transform.scrollTop + props.y; + } + }, + } ); + + // Dismiss animations if disabled. + return prefersReducedMotion ? + {} : + { + transformOrigin: 'center', + transform: interpolate( + [ + animationProps.x, + animationProps.y, + ], + ( x, y ) => x === 0 && y === 0 ? undefined : `translate3d(${ x }px,${ y }px,0)` + ), + zIndex: interpolate( + [ + animationProps.x, + animationProps.y, + ], + ( x, y ) => ! isSelected || ( x === 0 && y === 0 ) ? undefined : `1` + ), + }; +} + +export default useMovingAnimation; diff --git a/gb-src/packages/block-editor/src/components/block-list/multi-controls.js b/gb-src/packages/block-editor/src/components/block-list/multi-controls.js new file mode 100644 index 0000000000000..e4dd88aeda030 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/multi-controls.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockMover from '../block-mover'; + +function BlockListMultiControls( { + multiSelectedBlockClientIds, + isSelecting, +} ) { + if ( isSelecting ) { + return null; + } + + return ( + <BlockMover + clientIds={ multiSelectedBlockClientIds } + /> + ); +} + +export default withSelect( ( select ) => { + const { + getMultiSelectedBlockClientIds, + isMultiSelecting, + } = select( 'core/block-editor' ); + const clientIds = getMultiSelectedBlockClientIds(); + + return { + multiSelectedBlockClientIds: clientIds, + isSelecting: isMultiSelecting(), + }; +} )( BlockListMultiControls ); diff --git a/gb-src/packages/block-editor/src/components/block-list/style.native.scss b/gb-src/packages/block-editor/src/components/block-list/style.native.scss new file mode 100644 index 0000000000000..b6961600b95c6 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/style.native.scss @@ -0,0 +1,70 @@ +.container { + flex: 1; + justify-content: flex-start; + background-color: #fff; +} + +.list { + flex: 1; +} + +.switch { + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin: 10px; +} + +.switchLabel { + margin-left: 10px; +} + +.lineStyleAddHere { + flex: 1; + background-color: #0087be; // blue_wordpress + align-self: center; + height: 2px; +} + +.labelStyleAddHere { + flex: 1; + text-align: center; + font-family: $default-monospace-font; + font-size: 12px; + font-weight: bold; +} + +.containerStyleAddHere { + flex-direction: row; + background-color: $white; +} + +.blockHolderSemiBordered { + border-top-width: 1px; + border-bottom-width: 1px; + border-left-width: 0; + border-right-width: 0; +} + +.blockHolderFullBordered { + border-top-width: 1px; + border-bottom-width: 1px; + border-left-width: 1px; + border-right-width: 1px; +} + +.blockContainerFocused { + background-color: $white; + padding-left: 16; + padding-right: 16; + padding-top: 12; + padding-bottom: 0; // will be flushed into inline toolbar height +} + +.blockHolderFocused { + border-color: $gray-lighten-30; +} + +.blockListFooter { + height: 80px; +} diff --git a/gb-src/packages/block-editor/src/components/block-list/style.scss b/gb-src/packages/block-editor/src/components/block-list/style.scss new file mode 100644 index 0000000000000..e6f03bc71a4be --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/style.scss @@ -0,0 +1,1215 @@ +.block-editor-block-list__layout .components-draggable__clone { + // Hide the Block UI when dragging the block. + // This ensures the page scroll properly (no sticky elements). + .block-editor-block-contextual-toolbar { + // It's probably okay to use !important here to avoid over-complicating the selector. + display: none !important; + } +} + +.block-editor-block-list__layout .block-editor-block-list__block.is-selected { // Needs specificity to override inherited styles. + // While block is being dragged, dim the slot dragged from, and hide some UI. + &.is-dragging { + .block-editor-block-list__block-edit::before { + border: none; + } + + > .block-editor-block-list__block-edit > * { + background: $light-gray-100; + } + + > .block-editor-block-list__block-edit > * > * { + visibility: hidden; + } + + .block-editor-block-mover, + .block-editor-block-contextual-toolbar { + display: none; + } + } + + > .block-editor-block-list__block-edit .reusable-block-edit-panel * { + z-index: z-index(".block-editor-block-list__block-edit .reusable-block-edit-panel *"); + } +} + + +/** + * General layout + */ + +.block-editor-block-list__layout { + // Make room in the main content column for the side UI. + // The side UI uses negative margins to position itself so as to not affect the block width. + @include break-small() { + padding-left: $block-container-side-padding; + padding-right: $block-container-side-padding; + } + + // Don't add side padding for nested blocks. + .block-editor-block-list__block & { + // Compensate for side UI. + padding-left: 0; + padding-right: 0; + + // Compensate for block padding. + margin-left: -$block-padding; + margin-right: -$block-padding; + } +} + +.block-editor-block-list__layout .block-editor-block-list__block { + position: relative; + padding-left: $block-padding; + padding-right: $block-padding; + + // Break long strings of text without spaces so they don't overflow the block. + overflow-wrap: break-word; + + @include break-small() { + // The block mover needs to stay inside the block to allow clicks when hovering the block-. + padding-left: $block-padding + $block-side-ui-width + $block-side-ui-clearance - $border-width; + padding-right: $block-padding + $block-side-ui-width + $block-side-ui-clearance - $border-width; + } + + /** + * Notices + */ + + .components-placeholder .components-with-notices-ui { + margin: -10px 20px 12px 20px; + width: calc(100% - 40px); + } + + .components-with-notices-ui { + margin: 0 0 12px 0; + width: 100%; + + .components-notice { + margin-left: 0; + margin-right: 0; + + .components-notice__content { + font-size: $default-font-size; + } + } + } + + /** + * Block border layout + */ + + .block-editor-block-list__block-edit { + position: relative; + + &::before { + z-index: z-index(".block-editor-block-list__block-edit::before"); + content: ""; + position: absolute; + border: $border-width solid transparent; + border-left: none; + box-shadow: none; + pointer-events: none; + transition: border-color 0.1s linear, border-style 0.1s linear, box-shadow 0.1s linear; + @include reduce-motion("transition"); + + // Include a transparent outline for Windows High Contrast mode. + outline: $border-width solid transparent; + + // Go edge-to-edge on mobile. + right: -$block-padding; + left: -$block-padding; + top: -$block-padding; + bottom: -$block-padding; + } + } + + // Selected style. + &.is-selected { + + > .block-editor-block-list__block-edit::before { + // Use opacity to work in various editor styles. + border-color: $dark-opacity-light-800; + box-shadow: inset $block-left-border-width 0 0 0 $dark-gray-500; + + .is-dark-theme & { + border-color: $light-opacity-light-800; + box-shadow: inset $block-left-border-width 0 0 0 $light-gray-600; + } + + // Switch to outset borders on larger screens. + @include break-small() { + box-shadow: -$block-left-border-width 0 0 0 $dark-gray-500; + + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $light-gray-600; + } + } + } + + &.is-navigate-mode > .block-editor-block-list__block-edit::before { + border-color: $blue-medium-focus; + box-shadow: inset $block-left-border-width 0 0 0 $blue-medium-focus; + + @include break-small() { + box-shadow: -$block-left-border-width 0 0 0 $blue-medium-focus; + } + } + } + + // Hover style. + &.is-hovered:not(.is-navigate-mode) > .block-editor-block-list__block-edit::before { + box-shadow: -$block-left-border-width 0 0 0 $dark-opacity-light-500; + + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $light-opacity-light-400; + } + } + + // Spotlight mode. + &.is-focus-mode:not(.is-multi-selected) { + opacity: 0.5; + transition: opacity 0.1s linear; + @include reduce-motion("transition"); + + &:not(.is-focused) .block-editor-block-list__block, + &.is-focused { + opacity: 1; + } + } + + // Add extra border to parent blocks when their children are selected. + &.has-child-selected { + + > .block-editor-block-list__block-edit::before { + border: $border-width dashed $dark-opacity-light-600; + + .is-dark-theme & { + border-color: $light-opacity-light-500; + } + } + + > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected) > .block-editor-block-list__block-edit::before, + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-cover__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected) > .block-editor-block-list__block-edit::before, + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-group__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected) > .block-editor-block-list__block-edit::before { + border: $border-width dashed $dark-opacity-light-600; + + .is-dark-theme & { + border-color: $light-opacity-light-500; + } + } + + &.is-hovered > .block-editor-block-list__block-edit::before, + > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block.is-hovered:not(.is-selected) > .block-editor-block-list__block-edit::before, + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-cover__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block.is-hovered:not(.is-selected) > .block-editor-block-list__block-edit::before, + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-group__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block.is-hovered:not(.is-selected) > .block-editor-block-list__block-edit::before { + border-style: solid; + border-color: $dark-opacity-light-500; + border-left-color: transparent; + + .is-dark-theme & { + border-color: $light-opacity-light-400; + border-left-color: transparent; + } + } + } + + // Add extra border to child blocks when they are selected. + &.is-selected { + + > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected), + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-cover__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected), + > .block-editor-block-list__block-edit > [data-block] > div > .wp-block-group__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block:not(.is-selected) { + + > .block-editor-block-list__block-edit::before { + border: $border-width dashed $dark-opacity-light-600; + + .is-dark-theme & { + border-color: $light-opacity-light-500; + } + } + + &.is-hovered > .block-editor-block-list__block-edit::before { + border-style: solid; + border-color: $dark-opacity-light-500; + border-left-color: transparent; + + .is-dark-theme & { + border-color: $light-opacity-light-400; + border-left-color: transparent; + } + } + } + } +} + +/** + * Cross-block selection + */ + +.block-editor-block-list__layout .block-editor-block-list__block { + ::-moz-selection { + background-color: $blue-medium-highlight; + } + + ::selection { + background-color: $blue-medium-highlight; + } + + // Selection style for multiple blocks. + &.is-multi-selected *::selection { + background-color: transparent; + } + + &.is-multi-selected .block-editor-block-list__block-edit::before { + background: $blue-medium-highlight; + + // Use opacity to work in various editor styles. + mix-blend-mode: multiply; + + // Collapse extra vertical padding on selection. + top: -$block-padding; + bottom: -$block-padding; + + .is-dark-theme & { + mix-blend-mode: soft-light; + } + } +} + + +/** + * Block styles and alignments + */ + +.block-editor-block-list__layout .block-editor-block-list__block { + &.has-warning { + min-height: ( $block-padding + $block-spacing ) * 2; + } + + // Warnings + &.has-warning .block-editor-block-list__block-edit { + // When a block has a warning, you shouldn't be able to manipulate the contents. + > * { + pointer-events: none; + user-select: none; + } + + // Allow the warning action buttons to be manipulable. + .block-editor-warning { + pointer-events: all; + } + } + + &.has-warning .block-editor-block-list__block-edit::before { + // Use opacity to work in various editor styles. + border-color: $dark-opacity-light-500; + border-left: $border-width solid $dark-opacity-light-500; + + .is-dark-theme & { + border-color: $light-opacity-light-600; + } + } + + &.has-warning.is-selected .editor-block-list__block-edit::before { + // Use opacity to work in various editor styles. + border-color: $dark-opacity-light-800; + border-left-color: transparent; + + .is-dark-theme & { + border-color: $light-opacity-light-800; + } + } + + &.has-warning .block-editor-block-list__block-edit::after { + content: ""; + position: absolute; + background-color: rgba($light-gray-100, 0.4); + + top: -$block-padding; + bottom: -$block-padding; + right: -$block-padding; + left: -$block-padding; + } + + // Avoid conflict with the multi-selection highlight color. + &.has-warning.is-multi-selected .block-editor-block-list__block-edit::after { + background-color: transparent; + } + + &.has-warning.is-selected .block-editor-block-list__block-edit::after { + bottom: ( $block-toolbar-height - $block-padding - $border-width ); + + @include break-small() { + bottom: -$block-padding; + } + } + + // Appender + &.is-typing .block-editor-block-list__side-inserter { + opacity: 0; + animation: none; + } + + .block-editor-block-list__side-inserter { + @include edit-post__fade-in-animation; + } + + // Reusable blocks + &.is-reusable > .block-editor-block-list__block-edit::before { + border: $border-width dashed $dark-opacity-light-500; + + .is-dark-theme & { + border-color: $light-opacity-light-600; + } + } + + &.is-reusable.is-selected > .block-editor-block-list__block-edit::before { + // Use opacity to work in various editor styles. + border-color: $dark-opacity-light-800; + border-left-color: transparent; + + .is-dark-theme & { + border-color: $light-opacity-light-800; + border-left-color: transparent; + } + } + + // Reusable Blocks clickthrough overlays + &.is-reusable > .block-editor-block-list__block-edit .block-editor-inner-blocks.has-overlay { + // Remove only the top click overlay. + &::after { + display: none; + } + + // Restore it for subsequent. + .block-editor-inner-blocks.has-overlay::after { + display: block; + } + } + + + // Alignments + &[data-align="left"], + &[data-align="right"] { + // Without z-index, won't be clickable as "above" adjacent content. + z-index: z-index(".block-editor-block-list__block {core/image aligned left or right}"); + width: 100%; + + // When images are floated, the block itself should collapse to zero height. + height: 0; + + // Hide block border when an image is floated. + .block-editor-block-list__block-edit { + &::before { + content: none; + } + + // This margin won't collapse on its own, so zero it out. + margin-top: 0; + } + + // Keep a 1px margin to compensate for the border/outline. + .block-editor-block-contextual-toolbar { + margin-bottom: $border-width; + } + + // Position toolbar better on mobile. + .block-editor-block-contextual-toolbar { + width: auto; + border-bottom: $border-width solid $light-gray-800; + bottom: auto; + + @include break-small() { + border-bottom: none; + } + } + } + + // Unlike most explicit left/right alignments, this one should be flipped by the auto-RTL system. + &[data-align="left"] .block-editor-block-contextual-toolbar { + left: 0; + right: auto; + } + + &[data-align="right"] .block-editor-block-contextual-toolbar { + left: auto; + right: 0; + } + + // Position the sticky toolbar correctly beyond the mobile breakpoint. + @include break-small() { + &[data-align="right"] .block-editor-block-contextual-toolbar, + &[data-align="left"] .block-editor-block-contextual-toolbar { + top: $block-padding; + } + } + + // Left + &[data-align="left"] { + // This is in the editor only; the image should be floated on the frontend. + > .block-editor-block-list__block-edit { + /*!rtl:begin:ignore*/ + float: left; + margin-right: 2em; + /*!rtl:end:ignore*/ + } + + // Align block toolbar to floated content. + @include break-small() { + .block-editor-block-toolbar { + /*!rtl:begin:ignore*/ + left: $block-padding; + right: auto; + /*!rtl:end:ignore*/ + } + } + } + + // Right + &[data-align="right"] { + // Right: This is in the editor only; the image should be floated on the frontend. + > .block-editor-block-list__block-edit { + /*!rtl:begin:ignore*/ + float: right; + margin-left: 2em; + /*!rtl:end:ignore*/ + } + + // Align block toolbar to floated content. + @include break-small() { + .block-editor-block-toolbar { + /*!rtl:begin:ignore*/ + right: $block-padding; + left: auto; + /*!rtl:end:ignore*/ + } + } + } + + // Wide and full-wide + &[data-align="full"], + &[data-align="wide"] { + clear: both; + + // Without z-index, the block toolbar will be below an adjecent float + z-index: z-index(".block-editor-block-list__block {core/image aligned wide or fullwide}"); + + // Mover and settings above + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + // This moves the menu up by the height of the button + border + padding. + top: -$block-side-ui-width - $block-padding - $block-side-ui-clearance - ( $border-width * 2 ); + bottom: auto; + min-height: 0; + height: auto; + width: auto; + + &::before { + content: none; + } + } + + &.is-multi-selected > .block-editor-block-mover .block-editor-block-mover__control, + > .block-editor-block-list__block-edit > .block-editor-block-mover .block-editor-block-mover__control { + float: left; + } + + // Beyond the mobile breakpoint, wide images stretch outside of the column. + // To center the toolbar, we make it inline-flex so the toolbar is not full-wide. + @include break-small () { + .block-editor-block-toolbar { + display: inline-flex; + } + } + + // If the block movers are visible, push the breadcrumb down to make room for them. + .block-editor-block-mover.is-visible + .block-editor-block-list__breadcrumb { + top: (-$block-padding - $block-left-border-width - ($grid-size-small / 2)); + } + + // Align block toolbar to floated content. + // Extra specificity is needed to avoid applying this to innerblocks. + @include break-small() { + > .editor-block-list__block-edit > .block-editor-block-contextual-toolbar > .block-editor-block-toolbar { + /*!rtl:begin:ignore*/ + left: $block-side-ui-width * 3 + ($grid-size-small * 1.5); + /*!rtl:end:ignore*/ + } + } + + @include break-xlarge() { + > .editor-block-list__block-edit > .block-editor-block-contextual-toolbar > .block-editor-block-toolbar { + /*!rtl:begin:ignore*/ + left: $block-padding; + /*!rtl:end:ignore*/ + } + } + } + + // Wide + &[data-align="wide"] { + // Position mover + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + left: -$block-padding + $border-width; + } + } + + // Full-wide + &[data-align="full"] { + // Position hover label on the left for the top level block. + > .block-editor-block-list__block-edit > .block-editor-block-list__breadcrumb { + left: 0; + } + + // Compensate for main container padding and subtract border. + @include break-small() { + margin-left: -$block-side-ui-width - $block-padding - $block-side-ui-clearance - $border-width; + margin-right: -$block-side-ui-width - $block-padding - $block-side-ui-clearance - $border-width; + } + + > .block-editor-block-list__block-edit { + margin-left: -$block-padding; + margin-right: -$block-padding; + + @include break-small() { + margin-left: -$block-side-ui-width - $block-side-ui-clearance - $block-padding; + margin-right: -$block-side-ui-width - $block-side-ui-clearance - $block-padding; + } + + // This explicitly sets the width of the block, to override + // `width: fit-content` from the image block. + figure { + width: 100%; + } + } + + > .block-editor-block-list__block-edit::before { + left: 0; + right: 0; + border-left-width: 0; + border-right-width: 0; + } + + // Position mover + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + left: $border-width; + } + } + + // Clear floats + &[data-clear="true"] { + float: none; + } + + // Dropzones + .block-editor-block-drop-zone { + top: -4px; + bottom: -3px; + margin: 0 $block-padding; + } + + // Hide appender shortcuts in nested blocks. + // This essentially duplicates the mobile styles for the appender component. + // It would be nice to be able to use element queries in that component instead https://github.com/tomhodgins/element-queries-spec + .block-editor-block-list__layout { + .block-editor-inserter-with-shortcuts { + display: none; + } + + .block-editor-block-list__empty-block-inserter, + .block-editor-default-block-appender .block-editor-inserter { + left: auto; + right: $grid-size; + } + } +} + + +/** + * Styles that affect inner-block containers (nested blocks). + */ + +// Hide trailing appender for non-empty blocks, until selected. +.block-editor-inner-blocks { + .block-editor-block-list__block + .block-list-appender { + display: none; + + // When a parent container is selected, show the appender. + .is-selected & { + display: block; + } + } + + // When a child of a parent is selected, show the adjacent appender. + .block-editor-block-list__block.is-selected + .block-list-appender { + display: block; + } + + /* @todo: + The two rules above can be simplified & combined when https://github.com/WordPress/gutenberg/pull/14961 is merged, + into the following: + + .is-selected &, + .has-child-selected & { + display: block; + } + */ +} + + +/** + * Left and right side UI; Unified toolbar on Mobile + */ + +.block-editor-block-list__block { + + // Left and right block settings and mover. + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + position: absolute; + width: $block-side-ui-width + $block-side-ui-clearance; + } + + // Position depending on whether selected or not. + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + top: -$block-padding - $border-width; + } + + // Elevate when block is selected or hovered. + @include break-small() { + &.is-multi-selected, + &.is-selected, + &.is-hovered { + .block-editor-block-mover { + z-index: z-index(".block-editor-block-list__block.is-{selected,hovered} .block-editor-block-mover"); + } + } + } + + // Left side UI. + &.is-multi-selected > .block-editor-block-mover, + > .block-editor-block-list__block-edit > .block-editor-block-mover { + padding-right: $block-side-ui-clearance; + + // Position for top level blocks. + left: -$block-side-ui-width - $block-side-ui-clearance - $block-padding - $border-width - $grid-size; + + // Hide on mobile, as mobile has a separate solution. + display: none; + @include break-small() { + display: block; + } + } + + &.is-multi-selected > .block-editor-block-mover { + left: -$block-side-ui-width - $block-side-ui-clearance; + } +} + + +/** + * Mobile unified toolbar. + */ + +.block-editor-block-list__block { + + // Show side UI inline below the block on mobile. + .block-editor-block-list__block-mobile-toolbar { + display: flex; + flex-direction: row; + + // Make room for the height of the block toolbar above. + transform: translateY($block-padding + $border-width); + margin-top: $block-toolbar-height; + margin-right: -$block-padding; + margin-left: -$block-padding; + border-top: $border-width solid $light-gray-800; + height: $block-toolbar-height; + + @include break-small() { + display: none; + } + + // Add a white background to prevent the block's left border from showing through. + background-color: $white; + + // Show a shadow below the selected block to imply separation. + box-shadow: $shadow-below-only; + @include break-small() { + box-shadow: none; + } + + // Movers, inserter, trash, and ellipsis. + .block-editor-inserter { + position: relative; + left: auto; + top: auto; + margin: 0; + } + + .block-editor-inserter__toggle, + .block-editor-block-mover__control { + width: $icon-button-size; + height: $icon-button-size; + border-radius: $radius-round-rectangle; + padding: 3px; + margin: 0; + justify-content: center; + align-items: center; + + .dashicon { + margin: auto; + } + } + + // Movers + .block-editor-block-mover { + display: flex; + margin-right: auto; + + .block-editor-inserter, + .block-editor-block-mover__control { + float: left; + } + } + } + + // Reset negative margins on mobile for full-width. + &[data-align="full"] .block-editor-block-list__block-mobile-toolbar { + margin-left: 0; + margin-right: 0; + } +} + + +/** + * In-Canvas Inserter + */ + +.block-editor-block-list .block-editor-inserter { + margin: $grid-size; + cursor: move; // Fallback for IE/Edge < 14 + cursor: grab; +} + +// Insertion point (includes inbetween inserter and insertion indicator) +.block-editor-block-list__insertion-point { + position: relative; + z-index: z-index(".block-editor-block-list__insertion-point"); + margin-top: -$block-padding; +} + +.block-editor-block-list__insertion-point-indicator { + position: absolute; + top: calc(50% - #{ $border-width }); + height: 2px; + left: 0; + right: 0; + background: theme(primary); +} + +// This is the clickable plus. +.block-editor-block-list__insertion-point-inserter { + // Don't show on mobile. + display: none; + @include break-mobile() { + display: flex; + } + + position: absolute; + bottom: auto; + left: 0; + right: 0; + justify-content: center; + height: $block-padding + 8px; + + // Show a clickable plus. + .block-editor-inserter__toggle { + border-radius: 50%; + color: $blue-medium-focus; + background: $white; + height: $block-padding * 2; + width: $block-padding * 2; + padding: $grid-size-small; + + &:not(:disabled):not([aria-disabled="true"]):hover { + box-shadow: none; + } + } + + // Hide both the button until hovered. + opacity: 0; + transition: opacity 0.1s linear; + @include reduce-motion("transition"); + + &:hover, + &.is-visible { + opacity: 1; + } +} + +// Don't show the sibling inserter before the selected block. +.edit-post-layout:not(.has-fixed-toolbar) { + // The child selector is necessary for this to work properly in nested contexts. + .is-selected > .block-editor-block-list__insertion-point > .block-editor-block-list__insertion-point-inserter, + .is-focused > .block-editor-block-list__insertion-point > .block-editor-block-list__insertion-point-inserter { + opacity: 0; + pointer-events: none; + + &:hover, + &.is-visible { + opacity: 1; + pointer-events: auto; + } + } +} + +// This is the edge-to-edge hover area that contains the plus. +.block-editor-block-list__block { + > .block-editor-block-list__insertion-point { + position: absolute; + top: -$block-padding - $block-spacing / 2; + + // Matches the whole empty space between two blocks. + height: $block-padding * 2; + bottom: auto; + + // Go edge to edge on mobile. + left: 0; + right: 0; + + // Beyond mobile, make sure the toolbar overlaps the hover style. + @include break-small() { + left: -$border-width; + right: -$border-width; + } + } + + &[data-align="full"] > .block-editor-block-list__insertion-point { + left: 0; + right: 0; + } +} + +.block-editor-block-list__block .block-editor-block-list__block-html-textarea { + display: block; + margin: 0; + width: 100%; + border: none; + outline: none; + box-shadow: none; + resize: none; + overflow: hidden; + font-family: $editor-html-font; + font-size: $text-editor-font-size; + line-height: 150%; + transition: padding 0.2s linear; + @include reduce-motion("transition"); + + &:focus { + box-shadow: none; + } +} + + +/** + * Block Toolbar when contextual. + */ + +.block-editor-block-list__block { + .block-editor-block-contextual-toolbar { + z-index: z-index(".block-editor-block-contextual-toolbar"); + white-space: nowrap; + text-align: left; + pointer-events: none; + + // Position toolbar below the block on mobile. + position: absolute; + bottom: $block-toolbar-height - $block-padding - $border-width; + left: -$block-padding; + right: -$block-padding; + + // Paint the borders on the toolbar itself on mobile. + border-top: $border-width solid $light-gray-800; + .components-toolbar { + border-top: none; + border-bottom: none; + } + + @include break-small() { + border-top: none; + .components-toolbar { + border-top: $border-width solid $light-gray-800; + border-bottom: $border-width solid $light-gray-800; + } + } + } + + // Floated items have special needs for the contextual toolbar position + the thicker left border. + &[data-align="left"] .block-editor-block-contextual-toolbar, + &[data-align="right"] .block-editor-block-contextual-toolbar { + margin-bottom: $border-width; + margin-top: -$block-toolbar-height; + + // Display the box-shadow on the parent element. + box-shadow: -$block-left-border-width 0 0 0 $dark-gray-500; + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $light-gray-600; + } + + @include break-small() { + box-shadow: none; + } + + .editor-block-toolbar { + border-left: none; + } + } + + // Make block toolbar full width on mobile. + .block-editor-block-contextual-toolbar { + margin-left: 0; + margin-right: 0; + @include break-small() { + margin-left: -$block-padding - $border-width; + margin-right: -$block-padding - $border-width; + } + } + + // For floats, compensate for this so content doesn't grow smaller. + &[data-align="left"] .block-editor-block-contextual-toolbar { + /*rtl:ignore*/ + margin-right: $block-padding + $border-width; + } + + &[data-align="right"] .block-editor-block-contextual-toolbar { + /*rtl:ignore*/ + margin-left: $block-padding + $border-width; + } + + // Reset pointer-events on children. + .block-editor-block-contextual-toolbar > * { + pointer-events: auto; + } + + // Full-aligned blocks have negative margins on the parent of the toolbar, so additional position adjustment is not required. + &[data-align="full"] .block-editor-block-contextual-toolbar { + left: 0; + right: 0; + } +} + +// Enable toolbar footprint collapsing +.block-editor-block-contextual-toolbar { + // Position the contextual toolbar above the block. + .block-editor-block-list__block & { + @include break-small() { + bottom: auto; + left: auto; + right: auto; + box-shadow: none; + + // Move the block toolbar out of the flow using translate. + transform: translateY(-$block-toolbar-height -$block-padding -$border-width); + + // IE11 does not support `position: sticky`. + @supports (position: sticky) { + position: sticky; + + // Compensate for translate, so the sticky sticks to the top. + top: $block-toolbar-height + $block-padding; + } + } + } + + .block-editor-block-list__block[data-align="left"] & { + // RTL note: this rule should not be auto-flipped based on direction. + /*rtl:ignore*/ + float: left; + } + + .block-editor-block-list__block[data-align="right"] & { + // RTL note: this rule should not be auto-flipped based on direction. + /*rtl:ignore*/ + float: right; + + // IE11 moves the toolbar to the right because the parent container doesn't expand. + // A min-width mitigates this. + min-width: 200px; + + // IE11 does not support `position: sticky`. + @supports (position: sticky) { + min-width: 0; + } + } + + .block-editor-block-list__block[data-align="left"] &, + .block-editor-block-list__block[data-align="right"] & { + // Move the block toolbar out of the flow using translate, but less for floats. + transform: translateY(-$block-padding -$border-width); + } +} + +// Position the block toolbar when contextual. +.block-editor-block-contextual-toolbar .block-editor-block-toolbar { + width: 100%; + + @include break-small() { + width: auto; + + // Hide right border on desktop, where the .components-toolbar instead has a right border. + border-right: none; + + // This prevents floats from messing up the position of the block toolbar on floats-adjacent blocks when selected. + position: absolute; + left: $border-width; + top: $border-width; + } +} + + +/** + * Hover label + */ + +.block-editor-block-list__breadcrumb { + position: absolute; + line-height: 1; + z-index: z-index(".block-editor-block-list__breadcrumb"); + + // Position in the top left of the border. + left: -$block-padding - $block-left-border-width; + top: (($block-padding * -2) - $block-left-border-width); + + .components-toolbar { + border: none; + line-height: 1; + font-family: $default-font; + font-size: 11px; + padding: 4px 4px; + background: $light-gray-500; + color: $dark-gray-900; + transition: box-shadow 0.1s linear; + @include reduce-motion("transition"); + + .components-button { + font-size: inherit; + line-height: inherit; + padding: 0; + } + + .is-dark-theme & { + background: $dark-gray-600; + color: $white; + } + } + + // Remove negative left breadcrumb position for left aligned blocks. + [data-align="left"] & { + left: 0; + } + + // Right-align the breadcrumb for right-aligned blocks. + [data-align="right"] & { + left: auto; + right: 0; + } + + // In navigation mode, this should appear similarly to the block toolbar. + .is-navigate-mode & { + + // Position in the top left of the border. + left: -$block-padding; + top: -$block-toolbar-height - $block-padding; + + .components-toolbar { + background: $white; + border: $border-width solid $blue-medium-focus; + border-left: none; + box-shadow: inset $block-left-border-width 0 0 0 $blue-medium-focus; + height: $block-toolbar-height + $border-width; + font-size: $default-font-size; + line-height: $block-toolbar-height - $grid-size; + padding-left: $grid-size; + padding-right: $grid-size; + + .components-button { + box-shadow: none; + } + + .is-dark-theme & { + border-color: $light-opacity-light-800; + } + + @include break-small() { + box-shadow: -$block-left-border-width 0 0 0 $blue-medium-focus; + } + } + } +} + +.block-editor-block-list__descendant-arrow::before { + content: "→"; + display: inline-block; + padding: 0 4px; + + .rtl & { + content: "←"; + } +} + +.block-editor-block-list__block { + @include break-small { + // Increase the hover and selection area around blocks. + // This makes the blue hover line and the settings button appear even if + // the mouse cursor is technically outside the block. + // This improves usability by making it possible to click somewhat outside + // the block and select it. (eg. A fuzzy click target.) + &::before { + bottom: 0; + content: ""; + left: -$block-padding * 2; + position: absolute; + right: -$block-padding * 2; + top: 0; + } + + // Remove the fuzzy click area effect set above on nested blocks. + // It should only applies to top-level blocks; applying this rule to + // nested blocks will result in difficult-to-use and possibly overlapping + // click targets. + & &::before { + left: 0; + right: 0; + } + + // Don't use this for full-wide blocks, as there's no clearance to accommodate extra area on the side. + &[data-align="full"]::before { + content: none; + } + } +} + +.block-editor-block-list__block .block-editor-warning { + z-index: z-index(".block-editor-warning"); + position: relative; + margin-right: -$block-padding; + margin-left: -$block-padding; + + // Pull the warning upwards to the edge, and add a negative bottom margin to compensate. + margin-bottom: -$block-padding; + transform: translateY(-$block-padding); + + &.block-editor-block-list__block-crash-warning { + // The block crash warning has no block preview underneath it. + // The lack of a preview combined with the negative margin that + // the warning normally has results in crashed blocks overlapping + // any blocks that come after them. Resetting the margin to `auto` + // solves this. + margin-bottom: auto; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-list/test/block-html.js b/gb-src/packages/block-editor/src/components/block-list/test/block-html.js new file mode 100644 index 0000000000000..442e55de3f5bb --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-list/test/block-html.js @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { registerCoreBlocks } from '@wordpress/block-library'; + +/** + * Internal dependencies + */ +import { BlockHTML } from '../block-html'; + +describe( 'BlockHTML', () => { + beforeAll( () => { + registerCoreBlocks(); + } ); + + it( 'renders HTML editor', () => { + const wrapper = shallow( <BlockHTML block={ { isValid: true } } /> ); + + expect( wrapper.name() ).toBe( 'TextareaAutosize' ); + } ); + + describe( 'block content', () => { + it( 'use originalContent for an invalid block', () => { + const wrapper = shallow( <BlockHTML block={ { isValid: false, originalContent: 'test' } } /> ); + + expect( wrapper.state( 'html' ) ).toBe( 'test' ); + } ); + + it( 'use block content for a valid block', () => { + const block = createBlock( 'core/paragraph', { + content: 'test-block', + isValid: true, + } ); + + const wrapper = shallow( <BlockHTML block={ block } /> ); + + expect( wrapper.state( 'html' ) ).toBe( '<p>test-block</p>' ); + } ); + + it( 'onChange updates block html', () => { + const wrapper = shallow( <BlockHTML block={ { isValid: true } } /> ); + + wrapper.instance().onChange( { target: { value: 'update' } } ); + + expect( wrapper.state( 'html' ) ).toBe( 'update' ); + } ); + } ); + + describe( 'onBlur', () => { + const onChange = jest.fn(); + + beforeEach( () => { + onChange.mockClear(); + } ); + + it( 'onBlur updates valid HTML string in block', () => { + const block = createBlock( 'core/paragraph', { + content: 'test-block', + isValid: true, + } ); + const wrapper = shallow( <BlockHTML block={ block } onChange={ onChange } /> ); + + wrapper.instance().onChange( { target: { value: '<p>update</p>' } } ); + wrapper.instance().onBlur(); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange.mock.calls[ 0 ][ 2 ] ).toBe( '<p>update</p>' ); + expect( onChange.mock.calls[ 0 ][ 3 ] ).toBe( true ); + } ); + + it( 'onBlur updates invalid HTML string in block', () => { + const block = createBlock( 'core/paragraph', { + content: 'test-block', + isValid: true, + } ); + const wrapper = shallow( <BlockHTML block={ block } onChange={ onChange } /> ); + + wrapper.instance().onChange( { target: { value: '<p>update' } } ); + wrapper.instance().onBlur(); + + expect( console ).toHaveErrored(); + expect( console ).toHaveWarned(); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange.mock.calls[ 0 ][ 2 ] ).toBe( '<p>update' ); + expect( onChange.mock.calls[ 0 ][ 3 ] ).toBe( false ); + } ); + + it( 'onBlur resets block to default content if supplied empty string', () => { + const block = createBlock( 'core/paragraph', { + content: 'test-block', + isValid: true, + } ); + const wrapper = shallow( <BlockHTML block={ block } onChange={ onChange } /> ); + + wrapper.instance().onChange( { target: { value: '' } } ); + wrapper.instance().onBlur(); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange.mock.calls[ 0 ][ 2 ] ).toBe( '<p></p>' ); + expect( onChange.mock.calls[ 0 ][ 3 ] ).toBe( true ); + expect( wrapper.state( 'html' ) ).toBe( '<p></p>' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-mover/drag-handle.js b/gb-src/packages/block-editor/src/components/block-mover/drag-handle.js new file mode 100644 index 0000000000000..86551bf4be0e3 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/drag-handle.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import BlockDraggable from '../block-draggable'; + +export const IconDragHandle = ( { isVisible, className, icon, onDragStart, onDragEnd, blockElementId, clientId } ) => { + if ( ! isVisible ) { + return null; + } + + const dragHandleClassNames = classnames( 'editor-block-mover__control-drag-handle block-editor-block-mover__control-drag-handle', className ); + + return ( + <BlockDraggable + clientId={ clientId } + blockElementId={ blockElementId } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + > + { + ( { onDraggableStart, onDraggableEnd } ) => ( + <div + className={ dragHandleClassNames } + aria-hidden="true" + onDragStart={ onDraggableStart } + onDragEnd={ onDraggableEnd } + draggable + > + { icon } + </div> + ) } + </BlockDraggable> + ); +}; diff --git a/gb-src/packages/block-editor/src/components/block-mover/icons.js b/gb-src/packages/block-editor/src/components/block-mover/icons.js new file mode 100644 index 0000000000000..06bf5601f439d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/icons.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { Path, Polygon, SVG } from '@wordpress/components'; + +export const upArrow = ( + <SVG width="18" height="18" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"> + <Polygon points="9,4.5 3.3,10.1 4.8,11.5 9,7.3 13.2,11.5 14.7,10.1 " /> + </SVG> +); + +export const downArrow = ( + <SVG width="18" height="18" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"> + <Polygon points="9,13.5 14.7,7.9 13.2,6.5 9,10.7 4.8,6.5 3.3,7.9 " /> + </SVG> +); + +export const dragHandle = ( + <SVG width="18" height="18" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"> + <Path d="M13,8c0.6,0,1-0.4,1-1s-0.4-1-1-1s-1,0.4-1,1S12.4,8,13,8z M5,6C4.4,6,4,6.4,4,7s0.4,1,1,1s1-0.4,1-1S5.6,6,5,6z M5,10 + c-0.6,0-1,0.4-1,1s0.4,1,1,1s1-0.4,1-1S5.6,10,5,10z M13,10c-0.6,0-1,0.4-1,1s0.4,1,1,1s1-0.4,1-1S13.6,10,13,10z M9,6 + C8.4,6,8,6.4,8,7s0.4,1,1,1s1-0.4,1-1S9.6,6,9,6z M9,10c-0.6,0-1,0.4-1,1s0.4,1,1,1s1-0.4,1-1S9.6,10,9,10z" /> + </SVG> +); diff --git a/gb-src/packages/block-editor/src/components/block-mover/index.js b/gb-src/packages/block-editor/src/components/block-mover/index.js new file mode 100644 index 0000000000000..dd09dc1c86b7c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/index.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { first, last, partial, castArray } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { IconButton } from '@wordpress/components'; +import { getBlockType } from '@wordpress/blocks'; +import { Component } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { getBlockMoverDescription } from './mover-description'; +import { upArrow, downArrow, dragHandle } from './icons'; +import { IconDragHandle } from './drag-handle'; + +export class BlockMover extends Component { + constructor() { + super( ...arguments ); + this.state = { + isFocused: false, + }; + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + } + + onFocus() { + this.setState( { + isFocused: true, + } ); + } + + onBlur() { + this.setState( { + isFocused: false, + } ); + } + + render() { + const { onMoveUp, onMoveDown, isFirst, isLast, isDraggable, onDragStart, onDragEnd, clientIds, blockElementId, blockType, firstIndex, isLocked, instanceId, isHidden, rootClientId } = this.props; + const { isFocused } = this.state; + const blocksCount = castArray( clientIds ).length; + if ( isLocked || ( isFirst && isLast && ! rootClientId ) ) { + return null; + } + + // We emulate a disabled state because forcefully applying the `disabled` + // attribute on the button while it has focus causes the screen to change + // to an unfocused state (body as active element) without firing blur on, + // the rendering parent, leaving it unable to react to focus out. + return ( + <div className={ classnames( 'editor-block-mover block-editor-block-mover', { 'is-visible': isFocused || ! isHidden } ) }> + <IconButton + className="editor-block-mover__control block-editor-block-mover__control" + onClick={ isFirst ? null : onMoveUp } + icon={ upArrow } + label={ __( 'Move up' ) } + aria-describedby={ `block-editor-block-mover__up-description-${ instanceId }` } + aria-disabled={ isFirst } + onFocus={ this.onFocus } + onBlur={ this.onBlur } + /> + <IconDragHandle + className="editor-block-mover__control block-editor-block-mover__control" + icon={ dragHandle } + clientId={ clientIds } + blockElementId={ blockElementId } + isVisible={ isDraggable } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + /> + <IconButton + className="editor-block-mover__control block-editor-block-mover__control" + onClick={ isLast ? null : onMoveDown } + icon={ downArrow } + label={ __( 'Move down' ) } + aria-describedby={ `block-editor-block-mover__down-description-${ instanceId }` } + aria-disabled={ isLast } + onFocus={ this.onFocus } + onBlur={ this.onBlur } + /> + <span id={ `block-editor-block-mover__up-description-${ instanceId }` } className="editor-block-mover__description block-editor-block-mover__description"> + { + getBlockMoverDescription( + blocksCount, + blockType && blockType.title, + firstIndex, + isFirst, + isLast, + -1, + ) + } + </span> + <span id={ `block-editor-block-mover__down-description-${ instanceId }` } className="editor-block-mover__description block-editor-block-mover__description"> + { + getBlockMoverDescription( + blocksCount, + blockType && blockType.title, + firstIndex, + isFirst, + isLast, + 1, + ) + } + </span> + </div> + ); + } +} + +export default compose( + withSelect( ( select, { clientIds } ) => { + const { getBlock, getBlockIndex, getTemplateLock, getBlockRootClientId, getBlockOrder } = select( 'core/block-editor' ); + const normalizedClientIds = castArray( clientIds ); + const firstClientId = first( normalizedClientIds ); + const block = getBlock( firstClientId ); + const rootClientId = getBlockRootClientId( first( normalizedClientIds ) ); + const blockOrder = getBlockOrder( rootClientId ); + const firstIndex = getBlockIndex( firstClientId, rootClientId ); + const lastIndex = getBlockIndex( last( normalizedClientIds ), rootClientId ); + + return { + blockType: block ? getBlockType( block.name ) : null, + isLocked: getTemplateLock( rootClientId ) === 'all', + rootClientId, + firstIndex, + isFirst: firstIndex === 0, + isLast: lastIndex === blockOrder.length - 1, + }; + } ), + withDispatch( ( dispatch, { clientIds, rootClientId } ) => { + const { moveBlocksDown, moveBlocksUp } = dispatch( 'core/block-editor' ); + return { + onMoveDown: partial( moveBlocksDown, clientIds, rootClientId ), + onMoveUp: partial( moveBlocksUp, clientIds, rootClientId ), + }; + } ), + withInstanceId, +)( BlockMover ); diff --git a/gb-src/packages/block-editor/src/components/block-mover/index.native.js b/gb-src/packages/block-editor/src/components/block-mover/index.native.js new file mode 100644 index 0000000000000..8962dc22f49e4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/index.native.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { first, last, partial, castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { ToolbarButton } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { withInstanceId, compose } from '@wordpress/compose'; + +const BlockMover = ( { + isFirst, + isLast, + onMoveDown, + onMoveUp, + firstIndex, +} ) => ( + <> + <ToolbarButton + title={ ! isFirst ? + sprintf( + /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ + __( 'Move block up from row %1$s to row %2$s' ), + firstIndex + 1, + firstIndex + ) : + __( 'Move block up' ) + } + isDisabled={ isFirst } + onClick={ onMoveUp } + icon="arrow-up-alt" + extraProps={ { hint: __( 'Double tap to move the block up' ) } } + /> + + <ToolbarButton + title={ ! isLast ? + sprintf( + /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ + __( 'Move block down from row %1$s to row %2$s' ), + firstIndex + 1, + firstIndex + 2 + ) : + __( 'Move block down' ) + } + isDisabled={ isLast } + onClick={ onMoveDown } + icon="arrow-down-alt" + extraProps={ { hint: __( 'Double tap to move the block down' ) } } + /> + </> +); + +export default compose( + withSelect( ( select, { clientIds } ) => { + const { getBlockIndex, getBlockRootClientId, getBlockOrder } = select( 'core/block-editor' ); + const normalizedClientIds = castArray( clientIds ); + const firstClientId = first( normalizedClientIds ); + const rootClientId = getBlockRootClientId( first( normalizedClientIds ) ); + const blockOrder = getBlockOrder( rootClientId ); + const firstIndex = getBlockIndex( firstClientId, rootClientId ); + const lastIndex = getBlockIndex( last( normalizedClientIds ), rootClientId ); + + return { + firstIndex, + isFirst: firstIndex === 0, + isLast: lastIndex === blockOrder.length - 1, + }; + } ), + withDispatch( ( dispatch, { clientIds, rootClientId } ) => { + const { moveBlocksDown, moveBlocksUp } = dispatch( 'core/block-editor' ); + return { + onMoveDown: partial( moveBlocksDown, clientIds, rootClientId ), + onMoveUp: partial( moveBlocksUp, clientIds, rootClientId ), + }; + } ), + withInstanceId, +)( BlockMover ); diff --git a/gb-src/packages/block-editor/src/components/block-mover/mover-description.js b/gb-src/packages/block-editor/src/components/block-mover/mover-description.js new file mode 100644 index 0000000000000..44174ce85534c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/mover-description.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; + +/** + * Return a label for the block movement controls depending on block position. + * + * @param {number} selectedCount Number of blocks selected. + * @param {string} type Block type - in the case of a single block, should + * define its 'type'. I.e. 'Text', 'Heading', 'Image' etc. + * @param {number} firstIndex The index (position - 1) of the first block selected. + * @param {boolean} isFirst This is the first block. + * @param {boolean} isLast This is the last block. + * @param {number} dir Direction of movement (> 0 is considered to be going + * down, < 0 is up). + * + * @return {string} Label for the block movement controls. + */ +export function getBlockMoverDescription( selectedCount, type, firstIndex, isFirst, isLast, dir ) { + const position = ( firstIndex + 1 ); + + if ( selectedCount > 1 ) { + return getMultiBlockMoverDescription( selectedCount, firstIndex, isFirst, isLast, dir ); + } + + if ( isFirst && isLast ) { + // translators: %s: Type of block (i.e. Text, Image etc) + return sprintf( __( 'Block %s is the only block, and cannot be moved' ), type ); + } + + if ( dir > 0 && ! isLast ) { + // moving down + return sprintf( + // translators: 1: Type of block (i.e. Text, Image etc), 2: Position of selected block, 3: New position + __( 'Move %1$s block from position %2$d down to position %3$d' ), + type, + position, + ( position + 1 ) + ); + } + + if ( dir > 0 && isLast ) { + // moving down, and is the last item + // translators: %s: Type of block (i.e. Text, Image etc) + return sprintf( __( 'Block %s is at the end of the content and can’t be moved down' ), type ); + } + + if ( dir < 0 && ! isFirst ) { + // moving up + return sprintf( + // translators: 1: Type of block (i.e. Text, Image etc), 2: Position of selected block, 3: New position + __( 'Move %1$s block from position %2$d up to position %3$d' ), + type, + position, + ( position - 1 ) + ); + } + + if ( dir < 0 && isFirst ) { + // moving up, and is the first item + // translators: %s: Type of block (i.e. Text, Image etc) + return sprintf( __( 'Block %s is at the beginning of the content and can’t be moved up' ), type ); + } +} + +/** + * Return a label for the block movement controls depending on block position. + * + * @param {number} selectedCount Number of blocks selected. + * @param {number} firstIndex The index (position - 1) of the first block selected. + * @param {boolean} isFirst This is the first block. + * @param {boolean} isLast This is the last block. + * @param {number} dir Direction of movement (> 0 is considered to be going + * down, < 0 is up). + * + * @return {string} Label for the block movement controls. + */ +export function getMultiBlockMoverDescription( selectedCount, firstIndex, isFirst, isLast, dir ) { + const position = ( firstIndex + 1 ); + + if ( dir < 0 && isFirst ) { + return __( 'Blocks cannot be moved up as they are already at the top' ); + } + + if ( dir > 0 && isLast ) { + return __( 'Blocks cannot be moved down as they are already at the bottom' ); + } + + if ( dir < 0 && ! isFirst ) { + return sprintf( + // translators: 1: Number of selected blocks, 2: Position of selected blocks + _n( + 'Move %1$d block from position %2$d up by one place', + 'Move %1$d blocks from position %2$d up by one place', + selectedCount + ), + selectedCount, + position + ); + } + + if ( dir > 0 && ! isLast ) { + return sprintf( + // translators: 1: Number of selected blocks, 2: Position of selected blocks + _n( + 'Move %1$d block from position %2$d down by one place', + 'Move %1$d blocks from position %2$d down by one place', + selectedCount + ), + selectedCount, + position + ); + } +} diff --git a/gb-src/packages/block-editor/src/components/block-mover/style.scss b/gb-src/packages/block-editor/src/components/block-mover/style.scss new file mode 100644 index 0000000000000..a506b3282fad4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/style.scss @@ -0,0 +1,92 @@ +.block-editor-block-mover { + + @include break-small() { + min-height: $empty-paragraph-height; + opacity: 0; + background: $white; + border: 1px solid $dark-opacity-light-800; + border-radius: $radius-round-rectangle; + transition: box-shadow 0.2s ease-out; + @include reduce-motion("transition"); + + &.is-visible { + @include edit-post__fade-in-animation; + } + + &:hover { + box-shadow: $shadow-toolbar; + } + + // 24px is the smallest size of a good pressable button. + // With 3 pieces of side UI, that comes to a total of 72px. + // To vertically center against a 56px paragraph, move upwards 72px - 56px / 2. + // Don't do this for wide, fullwide, or mobile. + .block-editor-block-list__block:not([data-align="wide"]):not([data-align="full"]) & { + margin-top: -$grid-size; + } + } +} + +// Mover icon buttons. +.block-editor-block-mover__control { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + border: none; + box-shadow: none; + + // Carefully adjust the size of the side UI to fit one paragraph of text (56px). + width: $block-side-ui-width; + height: $icon-button-size-small; + + svg { + width: $block-side-ui-width; + height: $icon-button-size-small; + padding: #{ ($block-side-ui-width - $icon-button-size-small) / 2 } #{ ($block-side-ui-width - 18px) / 2 }; // This makes the SVG fill the whole available area, without scaling the artwork. + } + + &[aria-disabled="true"] { + cursor: default; + pointer-events: none; + color: $dark-opacity-300; + } + + @include break-small() { + color: $dark-opacity-300; + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + background-color: transparent; + box-shadow: none; + } + + &:focus:not(:disabled) { + background-color: transparent; + } + } + +} + +.block-editor-block-mover__control-drag-handle { + cursor: move; // Fallback for IE/Edge < 14 + cursor: grab; + fill: currentColor; + + &, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):active, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):focus { + box-shadow: none; + background: none; + color: $dark-opacity-500; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):active { + cursor: grabbing; + } +} + +.block-editor-block-mover__description { + display: none; +} diff --git a/gb-src/packages/block-editor/src/components/block-mover/test/index.js b/gb-src/packages/block-editor/src/components/block-mover/test/index.js new file mode 100644 index 0000000000000..48809ae790566 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/test/index.js @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { BlockMover } from '../'; +import { upArrow, downArrow, dragHandle } from '../icons'; + +describe( 'BlockMover', () => { + describe( 'basic rendering', () => { + const selectedClientIds = [ 'IisClientId', 'IisOtherClientId' ]; + + const blockType = { + title: 'yolo-block', + }; + + it( 'should not render if the editor is locked', () => { + const wrapper = shallow( <BlockMover isLocked /> ); + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'should render three icons with the following props', () => { + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + firstIndex={ 0 } + instanceId={ 1 } /> + ); + expect( blockMover.hasClass( 'block-editor-block-mover' ) ).toBe( true ); + + const moveUp = blockMover.childAt( 0 ); + const drag = blockMover.childAt( 1 ); + const moveDown = blockMover.childAt( 2 ); + const moveUpDesc = blockMover.childAt( 3 ); + const moveDownDesc = blockMover.childAt( 4 ); + expect( moveUp.name() ).toBe( 'ForwardRef(IconButton)' ); + expect( drag.type().name ).toBe( 'IconDragHandle' ); + expect( moveDown.name() ).toBe( 'ForwardRef(IconButton)' ); + expect( moveUp.props() ).toMatchObject( { + className: 'editor-block-mover__control block-editor-block-mover__control', + onClick: undefined, + label: 'Move up', + icon: upArrow, + 'aria-disabled': undefined, + 'aria-describedby': 'block-editor-block-mover__up-description-1', + } ); + expect( drag.props() ).toMatchObject( { + className: 'editor-block-mover__control block-editor-block-mover__control', + icon: dragHandle, + } ); + expect( moveDown.props() ).toMatchObject( { + className: 'editor-block-mover__control block-editor-block-mover__control', + onClick: undefined, + label: 'Move down', + icon: downArrow, + 'aria-disabled': undefined, + 'aria-describedby': 'block-editor-block-mover__down-description-1', + } ); + expect( moveUpDesc.text() ).toBe( 'Move 2 blocks from position 1 up by one place' ); + expect( moveDownDesc.text() ).toBe( 'Move 2 blocks from position 1 down by one place' ); + } ); + + it( 'should render the up arrow with a onMoveUp callback', () => { + const onMoveUp = ( event ) => event; + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + onMoveUp={ onMoveUp } + firstIndex={ 0 } + /> + ); + const moveUp = blockMover.childAt( 0 ); + expect( moveUp.prop( 'onClick' ) ).toBe( onMoveUp ); + } ); + + it( 'should render the drag handle with onDragStart and onDragEnd callback', () => { + const onDragStart = ( event ) => event; + const onDragEnd = ( event ) => event; + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + firstIndex={ 0 } + /> + ); + const dragHandler = blockMover.childAt( 1 ); + expect( dragHandler.prop( 'onDragStart' ) ).toBe( onDragStart ); + expect( dragHandler.prop( 'onDragEnd' ) ).toBe( onDragEnd ); + } ); + + it( 'should render the down arrow with a onMoveDown callback', () => { + const onMoveDown = ( event ) => event; + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + onMoveDown={ onMoveDown } + firstIndex={ 0 } + /> + ); + const moveDown = blockMover.childAt( 2 ); + expect( moveDown.prop( 'onClick' ) ).toBe( onMoveDown ); + } ); + + it( 'should not render the drag handle if block is not draggable', () => { + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + isDraggable={ false } + /> + ); + const dragHandler = blockMover.childAt( 1 ); + expect( dragHandler.type().name ).toBe( 'IconDragHandle' ); + expect( dragHandler.prop( 'isVisible' ) ).toBe( false ); + } ); + + it( 'should render with a disabled down arrow when the block isLast', () => { + const onMoveDown = ( event ) => event; + const blockMover = shallow( + <BlockMover + clientIds={ selectedClientIds } + blockType={ blockType } + onMoveDown={ onMoveDown } + isLast + firstIndex={ 0 } + /> + ); + const moveDown = blockMover.childAt( 2 ); + expect( moveDown.props() ).toMatchObject( { + onClick: null, + 'aria-disabled': true, + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-mover/test/mover-description.js b/gb-src/packages/block-editor/src/components/block-mover/test/mover-description.js new file mode 100644 index 0000000000000..63fa6ad52a3ba --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-mover/test/mover-description.js @@ -0,0 +1,112 @@ +/** + * Internal dependencies + */ +import { getBlockMoverDescription, getMultiBlockMoverDescription } from '../mover-description'; + +describe( 'block mover', () => { + const dirUp = -1, + dirDown = 1; + + describe( 'getBlockMoverDescription', () => { + const type = 'TestType'; + + it( 'Should generate a title for the first item moving up', () => { + expect( getBlockMoverDescription( + 1, + type, + 0, + true, + false, + dirUp, + ) ).toBe( + `Block ${ type } is at the beginning of the content and can’t be moved up` + ); + } ); + + it( 'Should generate a title for the last item moving down', () => { + expect( getBlockMoverDescription( + 1, + type, + 3, + false, + true, + dirDown, + ) ).toBe( `Block ${ type } is at the end of the content and can’t be moved down` ); + } ); + + it( 'Should generate a title for the second item moving up', () => { + expect( getBlockMoverDescription( + 1, + type, + 1, + false, + false, + dirUp, + ) ).toBe( `Move ${ type } block from position 2 up to position 1` ); + } ); + + it( 'Should generate a title for the second item moving down', () => { + expect( getBlockMoverDescription( + 1, + type, + 1, + false, + false, + dirDown, + ) ).toBe( `Move ${ type } block from position 2 down to position 3` ); + } ); + + it( 'Should generate a title for the only item in the list', () => { + expect( getBlockMoverDescription( + 1, + type, + 0, + true, + true, + dirDown, + ) ).toBe( `Block ${ type } is the only block, and cannot be moved` ); + } ); + } ); + + describe( 'getMultiBlockMoverDescription', () => { + it( 'Should generate a title moving multiple blocks up', () => { + expect( getMultiBlockMoverDescription( + 4, + 1, + false, + true, + dirUp, + ) ).toBe( 'Move 4 blocks from position 2 up by one place' ); + } ); + + it( 'Should generate a title moving multiple blocks down', () => { + expect( getMultiBlockMoverDescription( + 4, + 0, + true, + false, + dirDown, + ) ).toBe( 'Move 4 blocks from position 1 down by one place' ); + } ); + + it( 'Should generate a title for a selection of blocks at the top', () => { + expect( getMultiBlockMoverDescription( + 4, + 1, + true, + true, + dirUp, + ) ).toBe( 'Blocks cannot be moved up as they are already at the top' ); + } ); + + it( 'Should generate a title for a selection of blocks at the bottom', () => { + expect( getMultiBlockMoverDescription( + 4, + 2, + false, + true, + dirDown, + ) ).toBe( 'Blocks cannot be moved down as they are already at the bottom' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-navigation/dropdown.js b/gb-src/packages/block-editor/src/components/block-navigation/dropdown.js new file mode 100644 index 0000000000000..b53dd49bdf4bd --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-navigation/dropdown.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { IconButton, Dropdown, SVG, Path, KeyboardShortcuts } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { rawShortcut, displayShortcut } from '@wordpress/keycodes'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockNavigation from './'; + +const MenuIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> + <Path d="M5 5H3v2h2V5zm3 8h11v-2H8v2zm9-8H6v2h11V5zM7 11H5v2h2v-2zm0 8h2v-2H7v2zm3-2v2h11v-2H10z" /> + </SVG> +); + +function BlockNavigationDropdown( { hasBlocks, isDisabled } ) { + const isEnabled = hasBlocks && ! isDisabled; + + return ( + <Dropdown + contentClassName="editor-block-navigation__popover block-editor-block-navigation__popover" + renderToggle={ ( { isOpen, onToggle } ) => ( + <> + { isEnabled && <KeyboardShortcuts + bindGlobal + shortcuts={ { + [ rawShortcut.access( 'o' ) ]: onToggle, + } } + /> + } + <IconButton + icon={ MenuIcon } + aria-expanded={ isOpen } + onClick={ isEnabled ? onToggle : undefined } + label={ __( 'Block navigation' ) } + className="editor-block-navigation block-editor-block-navigation" + shortcut={ displayShortcut.access( 'o' ) } + aria-disabled={ ! isEnabled } + /> + </> + ) } + renderContent={ ( { onClose } ) => ( + <BlockNavigation onSelect={ onClose } /> + ) } + /> + ); +} + +export default withSelect( ( select ) => { + return { + hasBlocks: !! select( 'core/block-editor' ).getBlockCount(), + }; +} )( BlockNavigationDropdown ); diff --git a/gb-src/packages/block-editor/src/components/block-navigation/index.js b/gb-src/packages/block-editor/src/components/block-navigation/index.js new file mode 100644 index 0000000000000..f6b82955cb267 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-navigation/index.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withSelect, withDispatch } from '@wordpress/data'; +import { NavigableMenu } from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BlockNavigationList from './list'; + +function BlockNavigation( { rootBlock, rootBlocks, selectedBlockClientId, selectBlock } ) { + if ( ! rootBlocks || rootBlocks.length === 0 ) { + return null; + } + + const hasHierarchy = ( + rootBlock && ( + rootBlock.clientId !== selectedBlockClientId || + ( rootBlock.innerBlocks && rootBlock.innerBlocks.length !== 0 ) + ) + ); + + return ( + <NavigableMenu + role="presentation" + className="editor-block-navigation__container block-editor-block-navigation__container" + > + <p className="editor-block-navigation__label block-editor-block-navigation__label">{ __( 'Block navigation' ) }</p> + { hasHierarchy && ( + <BlockNavigationList + blocks={ [ rootBlock ] } + selectedBlockClientId={ selectedBlockClientId } + selectBlock={ selectBlock } + showNestedBlocks + /> + ) } + { ! hasHierarchy && ( + <BlockNavigationList + blocks={ rootBlocks } + selectedBlockClientId={ selectedBlockClientId } + selectBlock={ selectBlock } + /> + ) } + </NavigableMenu> + ); +} + +export default compose( + withSelect( ( select ) => { + const { + getSelectedBlockClientId, + getBlockHierarchyRootClientId, + getBlock, + getBlocks, + } = select( 'core/block-editor' ); + const selectedBlockClientId = getSelectedBlockClientId(); + return { + rootBlocks: getBlocks(), + rootBlock: selectedBlockClientId ? getBlock( getBlockHierarchyRootClientId( selectedBlockClientId ) ) : null, + selectedBlockClientId, + }; + } ), + withDispatch( ( dispatch, { onSelect = noop } ) => { + return { + selectBlock( clientId ) { + dispatch( 'core/block-editor' ).selectBlock( clientId ); + onSelect( clientId ); + }, + }; + } ) +)( BlockNavigation ); diff --git a/gb-src/packages/block-editor/src/components/block-navigation/list.js b/gb-src/packages/block-editor/src/components/block-navigation/list.js new file mode 100644 index 0000000000000..0a34d24ff4e4b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-navigation/list.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { map } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { getBlockType } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +export default function BlockNavigationList( { + blocks, + selectedBlockClientId, + selectBlock, + showNestedBlocks, +} ) { + return ( + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ + <ul className="editor-block-navigation__list block-editor-block-navigation__list" role="list"> + { map( blocks, ( block ) => { + const blockType = getBlockType( block.name ); + const isSelected = block.clientId === selectedBlockClientId; + + return ( + <li key={ block.clientId }> + <div className="editor-block-navigation__item block-editor-block-navigation__item"> + <Button + className={ classnames( 'editor-block-navigation__item-button block-editor-block-navigation__item-button', { + 'is-selected': isSelected, + } ) } + onClick={ () => selectBlock( block.clientId ) } + > + <BlockIcon icon={ blockType.icon } showColors /> + { blockType.title } + { isSelected && <span className="screen-reader-text">{ __( '(selected block)' ) }</span> } + </Button> + </div> + { showNestedBlocks && !! block.innerBlocks && !! block.innerBlocks.length && ( + <BlockNavigationList + blocks={ block.innerBlocks } + selectedBlockClientId={ selectedBlockClientId } + selectBlock={ selectBlock } + showNestedBlocks + /> + ) } + </li> + ); + } ) } + </ul> + /* eslint-enable jsx-a11y/no-redundant-roles */ + ); +} diff --git a/gb-src/packages/block-editor/src/components/block-navigation/style.scss b/gb-src/packages/block-editor/src/components/block-navigation/style.scss new file mode 100644 index 0000000000000..fbd5a71c989bd --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-navigation/style.scss @@ -0,0 +1,91 @@ +$tree-border-width: 2px; +$tree-item-height: 36px; + +.block-editor-block-navigation__container { + padding: $grid-size - $border-width; +} + +.block-editor-block-navigation__label { + margin: 0 0 $grid-size; + color: $dark-gray-300; +} + +.block-editor-block-navigation__list, +.block-editor-block-navigation__paragraph { + padding: 0; + margin: 0; +} + +.block-editor-block-navigation__list .block-editor-block-navigation__list { + margin-top: 2px; + border-left: $tree-border-width solid $light-gray-900; + margin-left: 1em; + + .block-editor-block-navigation__list { + margin-left: 1.5em; + } + + .block-editor-block-navigation__item { + position: relative; + + &::before { + position: absolute; + left: 0; + background: $light-gray-900; + width: 0.5em; + height: $tree-border-width; + content: ""; + top: calc(50% - #{ $tree-border-width / 2 }); + } + } + + .block-editor-block-navigation__item-button { + margin-left: 0.8em; + width: calc(100% - 0.8em); + } + + & > li:last-child { + position: relative; + &::after { + position: absolute; + content: ""; + background: $white; + top: ($tree-item-height + $tree-border-width ) / 2; + bottom: 0; + left: -$tree-border-width; + width: $tree-border-width; + } + } +} + +.block-editor-block-navigation__item-button { + display: flex; + align-items: center; + width: 100%; + padding: 6px; + text-align: left; + color: $dark-gray-600; + border-radius: 4px; + + .block-editor-block-icon { + margin-right: 6px; + } + + &:hover:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__hover; + } + + &:focus:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__focus; + } + + &.is-selected, + &.is-selected:focus { + color: $dark-gray-700; + background: $light-gray-300; + } +} + +.components-popover.block-editor-block-navigation__popover { + z-index: z-index(".components-popover.block-editor-block-navigation__popover"); +} diff --git a/gb-src/packages/block-editor/src/components/block-preview/README.md b/gb-src/packages/block-editor/src/components/block-preview/README.md new file mode 100644 index 0000000000000..b415f2bbc2237 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-preview/README.md @@ -0,0 +1,31 @@ +BlockPreview +============ + +`<BlockPreview />` allows you to preview blocks. + +## Usage + +Render the component passing in the required props: + +```jsx +<BlockPreview + blocks={ blocks } + viewportWidth={ 800 } +/> +``` + +## Props + +### `blocks` +* **Type:** `Array|Object` +* **Default:** `undefined` + +A block instance (object) or a blocks array you would like to render a preview. + +### `viewportWidth` +* **Type:** `Int` +* **Default:** `700` + +Width of the preview container in pixels. Controls at what size the blocks will be rendered inside the preview. + +`viewportWidth` can be used to simulate how blocks look on different device sizes or to make sure make sure multiple previews will be rendered with the same scale, regardless of their content. diff --git a/gb-src/packages/block-editor/src/components/block-preview/index.js b/gb-src/packages/block-editor/src/components/block-preview/index.js new file mode 100644 index 0000000000000..8a6692a422a6f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-preview/index.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Disabled } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { useLayoutEffect, useState, useRef, useReducer, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BlockEditorProvider from '../provider'; +import BlockList from '../block-list'; +import { getBlockPreviewContainerDOMNode } from '../../utils/dom'; + +function ScaledBlockPreview( { blocks, viewportWidth, padding = 0 } ) { + const previewRef = useRef( null ); + + const [ isReady, setIsReady ] = useState( false ); + const [ previewScale, setPreviewScale ] = useState( 1 ); + const [ { x, y }, setPosition ] = useState( { x: 0, y: 0 } ); + + // Dynamically calculate the scale factor + useLayoutEffect( () => { + // Timer - required to account for async render of `BlockEditorProvider` + const timerId = setTimeout( () => { + const containerElement = previewRef.current; + if ( ! containerElement ) { + return; + } + + // If we're previewing a single block, scale the preview to fit it. + if ( blocks.length === 1 ) { + const block = blocks[ 0 ]; + const previewElement = getBlockPreviewContainerDOMNode( block.clientId, containerElement ); + if ( ! previewElement ) { + return; + } + + let containerElementRect = containerElement.getBoundingClientRect(); + containerElementRect = { + width: containerElementRect.width - ( padding * 2 ), + height: containerElementRect.height - ( padding * 2 ), + left: containerElementRect.left, + top: containerElementRect.top, + }; + const scaledElementRect = previewElement.getBoundingClientRect(); + + const scale = containerElementRect.width / scaledElementRect.width || 1; + const offsetX = ( -( scaledElementRect.left - containerElementRect.left ) * scale ) + padding; + const offsetY = ( containerElementRect.height > scaledElementRect.height * scale ) ? + ( ( containerElementRect.height - ( scaledElementRect.height * scale ) ) / 2 ) + padding : 0; + + setPreviewScale( scale ); + setPosition( { x: offsetX, y: offsetY } ); + + // Hack: we need to reset the scaled elements margins + previewElement.style.marginTop = '0'; + } else { + const containerElementRect = containerElement.getBoundingClientRect(); + setPreviewScale( containerElementRect.width / viewportWidth ); + } + + setIsReady( true ); + }, 100 ); + + // Cleanup + return () => { + if ( timerId ) { + window.clearTimeout( timerId ); + } + }; + }, [] ); + + if ( ! blocks || blocks.length === 0 ) { + return null; + } + + const previewStyles = { + transform: `scale(${ previewScale })`, + visibility: isReady ? 'visible' : 'hidden', + left: x, + top: y, + width: viewportWidth, + }; + + return ( + <div + ref={ previewRef } + className={ classnames( 'block-editor-block-preview__container editor-styles-wrapper', { + 'is-ready': isReady, + } ) } + aria-hidden + > + <Disabled style={ previewStyles } className="block-editor-block-preview__content"> + <BlockList /> + </Disabled> + </div> + ); +} + +export function BlockPreview( { blocks, viewportWidth = 700, padding, settings } ) { + const renderedBlocks = useMemo( () => castArray( blocks ), [ blocks ] ); + const [ recompute, triggerRecompute ] = useReducer( ( state ) => state + 1, 0 ); + useLayoutEffect( triggerRecompute, [ blocks ] ); + return ( + <BlockEditorProvider + value={ renderedBlocks } + settings={ settings } + > + { + /* + * The key prop is used to force recomputing the preview + * by remounting the component, ScaledBlockPreview is not meant to + * be rerendered. + */ + } + <ScaledBlockPreview + key={ recompute } + blocks={ renderedBlocks } + viewportWidth={ viewportWidth } + padding={ padding } + /> + </BlockEditorProvider> + ); +} + +/** + * BlockPreview renders a preview of a block or array of blocks. + * + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/block-preview/README.md + * + * @param {Array|Object} blocks A block instance (object) or an array of blocks to be previewed. + * @param {number} viewportWidth Width of the preview container in pixels. Controls at what size the blocks will be rendered inside the preview. Default: 700. + * @return {WPElement} Rendered element. + */ +export default withSelect( ( select ) => { + return { + settings: select( 'core/block-editor' ).getSettings(), + }; +} )( BlockPreview ); diff --git a/gb-src/packages/block-editor/src/components/block-preview/style.scss b/gb-src/packages/block-editor/src/components/block-preview/style.scss new file mode 100644 index 0000000000000..2316de61a8355 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-preview/style.scss @@ -0,0 +1,56 @@ +// These rules ensure the preview scales smoothly regardless of the container size. +.block-editor-block-preview__container { + // In the component, a top padding is provided as an inline style to provid an aspect-ratio. + // This positioning enables the content to sit on top of that padding to fit. + position: relative; + + // The preview component measures the pixel width of this item, so as to calculate the scale factor. + // But without this baseline width, it collapses to 0. + width: 100%; + + overflow: hidden; + &.is-ready { + overflow: visible; + } +} + +.block-editor-block-preview__content { + // This element receives inline styles for width, height, and transform-scale. + // Those inline styles are calculated to fit a perfect thumbnail. + + // Position above the padding. + position: absolute; + + // Vertical alignment. It works with the transform: translate(-50%, -50%)`, + top: 0; + left: 0; + + // Important to set the origin. + transform-origin: top left; + + // Resetting paddings, margins, and other. + text-align: initial; + margin: 0; + overflow: visible; + min-height: auto; + + .block-editor-block-list__layout, + .block-editor-block-list__block { + padding: 0; + } + + .editor-block-list__block-edit [data-block] { + margin: 0; + } + + > div section { + height: auto; + } + + .block-editor-block-list__insertion-point, + .block-editor-block-drop-zone, + .reusable-block-indicator, + .block-list-appender { + display: none; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-selection-clearer/index.js b/gb-src/packages/block-editor/src/components/block-selection-clearer/index.js new file mode 100644 index 0000000000000..be39e5df9e7de --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-selection-clearer/index.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +class BlockSelectionClearer extends Component { + constructor() { + super( ...arguments ); + + this.bindContainer = this.bindContainer.bind( this ); + this.clearSelectionIfFocusTarget = this.clearSelectionIfFocusTarget.bind( this ); + } + + bindContainer( ref ) { + this.container = ref; + } + + /** + * Clears the selected block on focus if the container is the target of the + * focus. This assumes no other descendents have received focus until event + * has bubbled to the container. + * + * @param {FocusEvent} event Focus event. + */ + clearSelectionIfFocusTarget( event ) { + const { + hasSelectedBlock, + hasMultiSelection, + clearSelectedBlock, + } = this.props; + + const hasSelection = ( hasSelectedBlock || hasMultiSelection ); + if ( event.target === this.container && hasSelection ) { + clearSelectedBlock(); + } + } + + render() { + return ( + <div + tabIndex={ -1 } + onFocus={ this.clearSelectionIfFocusTarget } + ref={ this.bindContainer } + { ...omit( this.props, [ + 'clearSelectedBlock', + 'hasSelectedBlock', + 'hasMultiSelection', + ] ) } + /> + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { hasSelectedBlock, hasMultiSelection } = select( 'core/block-editor' ); + + return { + hasSelectedBlock: hasSelectedBlock(), + hasMultiSelection: hasMultiSelection(), + }; + } ), + withDispatch( ( dispatch ) => { + const { clearSelectedBlock } = dispatch( 'core/block-editor' ); + return { clearSelectedBlock }; + } ), +] )( BlockSelectionClearer ); diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-convert-button.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-convert-button.js new file mode 100644 index 0000000000000..f27cac38b1df9 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-convert-button.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuItem } from '@wordpress/components'; + +export default function BlockConvertButton( { shouldRender, onClick, small } ) { + if ( ! shouldRender ) { + return null; + } + + const label = __( 'Convert to Blocks' ); + return ( + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ onClick } + icon="screenoptions" + > + { ! small && label } + </MenuItem> + ); +} diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-html-convert-button.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-html-convert-button.js new file mode 100644 index 0000000000000..3527d7d0d460c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-html-convert-button.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { rawHandler, getBlockContent } from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockConvertButton from './block-convert-button'; + +export default compose( + withSelect( ( select, { clientId } ) => { + const block = select( 'core/block-editor' ).getBlock( clientId ); + + return { + block, + shouldRender: ( block && block.name === 'core/html' ), + }; + } ), + withDispatch( ( dispatch, { block } ) => ( { + onClick: () => dispatch( 'core/block-editor' ).replaceBlocks( + block.clientId, + rawHandler( { HTML: getBlockContent( block ) } ), + ), + } ) ), +)( BlockConvertButton ); diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js new file mode 100644 index 0000000000000..d36ab6974e276 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuItem } from '@wordpress/components'; +import { getBlockType, hasBlockSupport } from '@wordpress/blocks'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +export function BlockModeToggle( { blockType, mode, onToggleMode, small = false, isCodeEditingEnabled = true } ) { + if ( ! hasBlockSupport( blockType, 'html', true ) || ! isCodeEditingEnabled ) { + return null; + } + + const label = mode === 'visual' ? + __( 'Edit as HTML' ) : + __( 'Edit visually' ); + + return ( + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ onToggleMode } + icon="html" + > + { ! small && label } + </MenuItem> + ); +} + +export default compose( [ + withSelect( ( select, { clientId } ) => { + const { getBlock, getBlockMode, getSettings } = select( 'core/block-editor' ); + const block = getBlock( clientId ); + const isCodeEditingEnabled = getSettings().codeEditingEnabled; + + return { + mode: getBlockMode( clientId ), + blockType: block ? getBlockType( block.name ) : null, + isCodeEditingEnabled, + }; + } ), + withDispatch( ( dispatch, { onToggle = noop, clientId } ) => ( { + onToggleMode() { + dispatch( 'core/block-editor' ).toggleBlockMode( clientId ); + onToggle(); + }, + } ) ), +] )( BlockModeToggle ); diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-first-item.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-first-item.js new file mode 100644 index 0000000000000..e01f943684ffe --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-first-item.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +const { Fill: __experimentalBlockSettingsMenuFirstItem, Slot } = createSlotFill( '__experimentalBlockSettingsMenuFirstItem' ); + +__experimentalBlockSettingsMenuFirstItem.Slot = Slot; + +export default __experimentalBlockSettingsMenuFirstItem; diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js new file mode 100644 index 0000000000000..a750b5b36cc18 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +const { Fill: __experimentalBlockSettingsMenuPluginsExtension, Slot } = createSlotFill( '__experimentalBlockSettingsMenuPluginsExtension' ); + +__experimentalBlockSettingsMenuPluginsExtension.Slot = Slot; + +export default __experimentalBlockSettingsMenuPluginsExtension; diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js b/gb-src/packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js new file mode 100644 index 0000000000000..46eda45141ad1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { getFreeformContentHandlerName, rawHandler, serialize } from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockConvertButton from './block-convert-button'; + +export default compose( + withSelect( ( select, { clientId } ) => { + const block = select( 'core/block-editor' ).getBlock( clientId ); + + return { + block, + shouldRender: ( block && block.name === getFreeformContentHandlerName() ), + }; + } ), + withDispatch( ( dispatch, { block } ) => ( { + onClick: () => dispatch( 'core/block-editor' ).replaceBlocks( + block.clientId, + rawHandler( { HTML: serialize( block ) } ) + ), + } ) ), +)( BlockConvertButton ); diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/index.js b/gb-src/packages/block-editor/src/components/block-settings-menu/index.js new file mode 100644 index 0000000000000..e4f7219d1a380 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/index.js @@ -0,0 +1,133 @@ +/** + * External dependencies + */ +import { castArray, flow } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, _n } from '@wordpress/i18n'; +import { + Toolbar, + DropdownMenu, + MenuGroup, + MenuItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { shortcuts } from '../block-editor-keyboard-shortcuts'; +import BlockActions from '../block-actions'; +import BlockModeToggle from './block-mode-toggle'; +import BlockHTMLConvertButton from './block-html-convert-button'; +import BlockUnknownConvertButton from './block-unknown-convert-button'; +import __experimentalBlockSettingsMenuFirstItem from './block-settings-menu-first-item'; +import __experimentalBlockSettingsMenuPluginsExtension from './block-settings-menu-plugins-extension'; + +const POPOVER_PROPS = { + className: 'block-editor-block-settings-menu__popover editor-block-settings-menu__popover', + position: 'bottom right', +}; + +export function BlockSettingsMenu( { clientIds } ) { + const blockClientIds = castArray( clientIds ); + const count = blockClientIds.length; + const firstBlockClientId = blockClientIds[ 0 ]; + + return ( + <BlockActions clientIds={ clientIds }> + { ( { + canDuplicate, + canInsertDefaultBlock, + isLocked, + onDuplicate, + onInsertAfter, + onInsertBefore, + onRemove, + } ) => ( + <Toolbar> + <DropdownMenu + icon="ellipsis" + label={ __( 'More options' ) } + className="block-editor-block-settings-menu" + popoverProps={ POPOVER_PROPS } + > + { ( { onClose } ) => ( + <> + <MenuGroup> + <__experimentalBlockSettingsMenuFirstItem.Slot + fillProps={ { onClose } } + /> + { count === 1 && ( + <BlockUnknownConvertButton + clientId={ firstBlockClientId } + /> + ) } + { count === 1 && ( + <BlockHTMLConvertButton + clientId={ firstBlockClientId } + /> + ) } + { canDuplicate && ( + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ flow( onClose, onDuplicate ) } + icon="admin-page" + shortcut={ shortcuts.duplicate.display } + > + { __( 'Duplicate' ) } + </MenuItem> + ) } + { canInsertDefaultBlock && ( + <> + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ flow( onClose, onInsertBefore ) } + icon="insert-before" + shortcut={ shortcuts.insertBefore.display } + > + { __( 'Insert Before' ) } + </MenuItem> + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ flow( onClose, onInsertAfter ) } + icon="insert-after" + shortcut={ shortcuts.insertAfter.display } + > + { __( 'Insert After' ) } + </MenuItem> + </> + ) } + { count === 1 && ( + <BlockModeToggle + clientId={ firstBlockClientId } + onToggle={ onClose } + /> + ) } + <__experimentalBlockSettingsMenuPluginsExtension.Slot + fillProps={ { clientIds, onClose } } + /> + </MenuGroup> + <MenuGroup> + { ! isLocked && ( + <MenuItem + className="editor-block-settings-menu__control block-editor-block-settings-menu__control" + onClick={ flow( onClose, onRemove ) } + icon="trash" + shortcut={ shortcuts.removeBlock.display } + > + { _n( 'Remove Block', 'Remove Blocks', count ) } + </MenuItem> + ) } + </MenuGroup> + </> + ) } + </DropdownMenu> + </Toolbar> + ) } + </BlockActions> + ); +} + +export default BlockSettingsMenu; diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/style.scss b/gb-src/packages/block-editor/src/components/block-settings-menu/style.scss new file mode 100644 index 0000000000000..d7b1ae4f5d7db --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/style.scss @@ -0,0 +1,7 @@ +.block-editor-block-settings-menu .components-dropdown-menu__toggle .dashicon { + transform: rotate(90deg); +} + +.block-editor-block-settings-menu__popover .components-dropdown-menu__menu { + padding: 0; +} diff --git a/gb-src/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js b/gb-src/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js new file mode 100644 index 0000000000000..d8bfef8771b87 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import ShallowRenderer from 'react-test-renderer/shallow'; + +/** + * Internal dependencies + */ +import { BlockModeToggle } from '../block-mode-toggle'; + +describe( 'BlockModeToggle', () => { + function getShallowRenderOutput( element ) { + const renderer = new ShallowRenderer(); + renderer.render( element ); + return renderer.getRenderOutput(); + } + + it( "should not render the HTML mode button if the block doesn't support it", () => { + const wrapper = getShallowRenderOutput( + <BlockModeToggle blockType={ { supports: { html: false } } } /> + ); + + expect( wrapper ).toBe( null ); + } ); + + it( 'should render the HTML mode button', () => { + const wrapper = getShallowRenderOutput( + <BlockModeToggle + blockType={ { supports: { html: true } } } + mode="visual" + /> + ); + const text = wrapper.props.children; + + expect( text ).toEqual( 'Edit as HTML' ); + } ); + + it( 'should render the Visual mode button', () => { + const wrapper = getShallowRenderOutput( + <BlockModeToggle + blockType={ { supports: { html: true } } } + mode="html" + /> + ); + const text = wrapper.props.children; + + expect( text ).toEqual( 'Edit visually' ); + } ); + + it( 'should not render the Visual mode button if code editing is disabled', () => { + const wrapper = getShallowRenderOutput( + <BlockModeToggle + blockType={ { supports: { html: true } } } + mode="html" + isCodeEditingEnabled={ false } + /> + ); + + expect( wrapper ).toBe( null ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-styles/index.js b/gb-src/packages/block-editor/src/components/block-styles/index.js new file mode 100644 index 0000000000000..25a2fd7b235cd --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-styles/index.js @@ -0,0 +1,170 @@ +/** + * External dependencies + */ +import { find, noop } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { withSelect, withDispatch } from '@wordpress/data'; +import TokenList from '@wordpress/token-list'; +import { ENTER, SPACE } from '@wordpress/keycodes'; +import { _x } from '@wordpress/i18n'; +import { getBlockType, cloneBlock, getBlockFromExample } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import BlockPreview from '../block-preview'; + +/** + * Returns the active style from the given className. + * + * @param {Array} styles Block style variations. + * @param {string} className Class name + * + * @return {Object?} The active style. + */ +export function getActiveStyle( styles, className ) { + for ( const style of new TokenList( className ).values() ) { + if ( style.indexOf( 'is-style-' ) === -1 ) { + continue; + } + + const potentialStyleName = style.substring( 9 ); + const activeStyle = find( styles, { name: potentialStyleName } ); + if ( activeStyle ) { + return activeStyle; + } + } + + return find( styles, 'isDefault' ); +} + +/** + * Replaces the active style in the block's className. + * + * @param {string} className Class name. + * @param {Object?} activeStyle The replaced style. + * @param {Object} newStyle The replacing style. + * + * @return {string} The updated className. + */ +export function replaceActiveStyle( className, activeStyle, newStyle ) { + const list = new TokenList( className ); + + if ( activeStyle ) { + list.remove( 'is-style-' + activeStyle.name ); + } + + list.add( 'is-style-' + newStyle.name ); + + return list.value; +} + +function BlockStyles( { + styles, + className, + onChangeClassName, + type, + block, + onSwitch = noop, + onHoverClassName = noop, +} ) { + if ( ! styles || styles.length === 0 ) { + return null; + } + + if ( ! type.styles && ! find( styles, 'isDefault' ) ) { + styles = [ + { + name: 'default', + label: _x( 'Default', 'block style' ), + isDefault: true, + }, + ...styles, + ]; + } + + const activeStyle = getActiveStyle( styles, className ); + function updateClassName( style ) { + const updatedClassName = replaceActiveStyle( className, activeStyle, style ); + onChangeClassName( updatedClassName ); + onHoverClassName( null ); + onSwitch(); + } + + return ( + <div className="editor-block-styles block-editor-block-styles"> + { styles.map( ( style ) => { + const styleClassName = replaceActiveStyle( className, activeStyle, style ); + return ( + <div + key={ style.name } + className={ classnames( + 'editor-block-styles__item block-editor-block-styles__item', { + 'is-active': activeStyle === style, + } + ) } + onClick={ () => updateClassName( style ) } + onKeyDown={ ( event ) => { + if ( ENTER === event.keyCode || SPACE === event.keyCode ) { + event.preventDefault(); + updateClassName( style ); + } + } } + onMouseEnter={ () => onHoverClassName( styleClassName ) } + onMouseLeave={ () => onHoverClassName( null ) } + role="button" + tabIndex="0" + aria-label={ style.label || style.name } + > + <div className="editor-block-styles__item-preview block-editor-block-styles__item-preview"> + <BlockPreview + viewportWidth={ 500 } + blocks={ + type.example ? + getBlockFromExample( block.name, { + attributes: { ...type.example.attributes, className: styleClassName }, + innerBlocks: type.example.innerBlocks, + } ) : + cloneBlock( block, { className: styleClassName } ) + } + /> + </div> + <div className="editor-block-styles__item-label block-editor-block-styles__item-label"> + { style.label || style.name } + </div> + </div> + ); + } ) } + </div> + ); +} + +export default compose( [ + withSelect( ( select, { clientId } ) => { + const { getBlock } = select( 'core/block-editor' ); + const { getBlockStyles } = select( 'core/blocks' ); + const block = getBlock( clientId ); + const blockType = getBlockType( block.name ); + + return { + block, + className: block.attributes.className || '', + styles: getBlockStyles( block.name ), + type: blockType, + }; + } ), + withDispatch( ( dispatch, { clientId } ) => { + return { + onChangeClassName( newClassName ) { + dispatch( 'core/block-editor' ).updateBlockAttributes( clientId, { + className: newClassName, + } ); + }, + }; + } ), +] )( BlockStyles ); diff --git a/gb-src/packages/block-editor/src/components/block-styles/style.scss b/gb-src/packages/block-editor/src/components/block-styles/style.scss new file mode 100644 index 0000000000000..398ee20cac35f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-styles/style.scss @@ -0,0 +1,56 @@ +.block-editor-block-styles { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.block-editor-block-styles__item { + width: calc(50% - #{ $grid-size-small }); + margin: $grid-size-small 0; + flex-shrink: 0; + cursor: pointer; + overflow: hidden; + border-radius: $radius-round-rectangle; + padding: $grid-size-small * 1.5; + padding-top: calc(50% * 0.75 - #{ $grid-size-small } * 1.5); + + &:focus { + @include block-style__focus(); + } + + &:hover { + @include block-style__hover; + } + + &.is-active { + @include block-style__is-active(); + + &:focus { + @include block-style__is-active-focus(); + } + } +} + +// Show a little preview thumbnail for style variations. +.block-editor-block-styles__item-preview { + outline: $border-width solid transparent; // Shown in Windows High Contrast mode. + padding: 0; + border: $border-width solid rgba($dark-gray-900, 0.2); + border-radius: $radius-round-rectangle; + display: flex; + overflow: hidden; + background: $white; + padding-top: 75%; + margin-top: -75%; + + .block-editor-block-preview__container { + padding-top: 0; + margin: 0; + margin-top: -75%; + } +} + +.block-editor-block-styles__item-label { + text-align: center; + padding: 4px 2px; +} diff --git a/gb-src/packages/block-editor/src/components/block-styles/test/index.js b/gb-src/packages/block-editor/src/components/block-styles/test/index.js new file mode 100644 index 0000000000000..639d92ed4aeaa --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-styles/test/index.js @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { getActiveStyle, replaceActiveStyle } from '../'; + +describe( 'getActiveStyle', () => { + it( 'Should return the undefined if no active style', () => { + const styles = [ + { name: 'small' }, + { name: 'big' }, + ]; + const className = 'custom-className'; + + expect( getActiveStyle( styles, className ) ).toBeUndefined(); + } ); + + it( 'Should return the default style if no active style', () => { + const styles = [ + { name: 'small' }, + { name: 'big', isDefault: true }, + ]; + const className = 'custom-className'; + + expect( getActiveStyle( styles, className ).name ).toBe( 'big' ); + } ); + + it( 'Should return the active style', () => { + const styles = [ + { name: 'small' }, + { name: 'big', isDefault: true }, + ]; + const className = 'this-is-custom is-style-small'; + + expect( getActiveStyle( styles, className ).name ).toBe( 'small' ); + } ); + + it( 'Should return the first active style', () => { + const styles = [ + { name: 'small' }, + { name: 'big', isDefault: true }, + ]; + const className = 'this-is-custom is-style-small is-style-big'; + + expect( getActiveStyle( styles, className ).name ).toBe( 'small' ); + } ); +} ); + +describe( 'replaceActiveStyle', () => { + it( 'Should add the new style if no active style', () => { + const activeStyle = undefined; + const newStyle = { name: 'small' }; + const className = 'custom-class'; + + expect( replaceActiveStyle( className, activeStyle, newStyle ) ) + .toBe( 'custom-class is-style-small' ); + } ); + + it( 'Should add the new style if no active style (no existing class)', () => { + const activeStyle = undefined; + const newStyle = { name: 'small' }; + const className = ''; + + expect( replaceActiveStyle( className, activeStyle, newStyle ) ) + .toBe( 'is-style-small' ); + } ); + + it( 'Should add the new style if no active style (unassigned default)', () => { + const activeStyle = { name: 'default' }; + const newStyle = { name: 'small' }; + const className = ''; + + expect( replaceActiveStyle( className, activeStyle, newStyle ) ) + .toBe( 'is-style-small' ); + } ); + + it( 'Should replace the previous active style', () => { + const activeStyle = { name: 'large' }; + const newStyle = { name: 'small' }; + const className = 'custom-class is-style-large'; + + expect( replaceActiveStyle( className, activeStyle, newStyle ) ) + .toBe( 'custom-class is-style-small' ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-switcher/index.js b/gb-src/packages/block-editor/src/components/block-switcher/index.js new file mode 100644 index 0000000000000..4f3510e4ebbb2 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/index.js @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import { castArray, filter, first, mapKeys, orderBy, uniq, map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Dropdown, IconButton, Toolbar, PanelBody, Path, SVG } from '@wordpress/components'; +import { getBlockType, getPossibleBlockTransformations, switchToBlockType, cloneBlock, getBlockFromExample } from '@wordpress/blocks'; +import { Component } from '@wordpress/element'; +import { DOWN } from '@wordpress/keycodes'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; +import BlockStyles from '../block-styles'; +import BlockPreview from '../block-preview'; +import BlockTypesList from '../block-types-list'; + +export class BlockSwitcher extends Component { + constructor() { + super( ...arguments ); + this.state = { + hoveredClassName: null, + }; + this.onHoverClassName = this.onHoverClassName.bind( this ); + } + + onHoverClassName( className ) { + this.setState( { hoveredClassName: className } ); + } + + render() { + const { blocks, onTransform, inserterItems, hasBlockStyles } = this.props; + const { hoveredClassName } = this.state; + + if ( ! blocks || ! blocks.length ) { + return null; + } + + const hoveredBlock = hoveredClassName ? blocks[ 0 ] : null; + const hoveredBlockType = hoveredClassName ? getBlockType( hoveredBlock.name ) : null; + + const itemsByName = mapKeys( inserterItems, ( { name } ) => name ); + const possibleBlockTransformations = orderBy( + filter( + getPossibleBlockTransformations( blocks ), + ( block ) => block && !! itemsByName[ block.name ] + ), + ( block ) => itemsByName[ block.name ].frecency, + 'desc' + ); + + // When selection consists of blocks of multiple types, display an + // appropriate icon to communicate the non-uniformity. + const isSelectionOfSameType = uniq( map( blocks, 'name' ) ).length === 1; + + let icon; + if ( isSelectionOfSameType ) { + const sourceBlockName = blocks[ 0 ].name; + const blockType = getBlockType( sourceBlockName ); + icon = blockType.icon; + } else { + icon = 'layout'; + } + + if ( ! hasBlockStyles && ! possibleBlockTransformations.length ) { + return ( + <Toolbar> + <IconButton + disabled + className="editor-block-switcher__no-switcher-icon block-editor-block-switcher__no-switcher-icon" + label={ __( 'Block icon' ) } + icon={ <BlockIcon icon={ icon } showColors /> } + /> + </Toolbar> + ); + } + + return ( + <Dropdown + position="bottom right" + className="editor-block-switcher block-editor-block-switcher" + contentClassName="editor-block-switcher__popover block-editor-block-switcher__popover" + renderToggle={ ( { onToggle, isOpen } ) => { + const openOnArrowDown = ( event ) => { + if ( ! isOpen && event.keyCode === DOWN ) { + event.preventDefault(); + event.stopPropagation(); + onToggle(); + } + }; + const label = ( + 1 === blocks.length ? + __( 'Change block type or style' ) : + sprintf( + _n( + 'Change type of %d block', + 'Change type of %d blocks', + blocks.length + ), + blocks.length + ) + ); + + return ( + <Toolbar> + <IconButton + className="editor-block-switcher__toggle block-editor-block-switcher__toggle" + onClick={ onToggle } + aria-haspopup="true" + aria-expanded={ isOpen } + label={ label } + tooltip={ label } + onKeyDown={ openOnArrowDown } + icon={ ( + <> + <BlockIcon icon={ icon } showColors /> + <SVG className="editor-block-switcher__transform block-editor-block-switcher__transform" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M6.5 8.9c.6-.6 1.4-.9 2.2-.9h6.9l-1.3 1.3 1.4 1.4L19.4 7l-3.7-3.7-1.4 1.4L15.6 6H8.7c-1.4 0-2.6.5-3.6 1.5l-2.8 2.8 1.4 1.4 2.8-2.8zm13.8 2.4l-2.8 2.8c-.6.6-1.3.9-2.1.9h-7l1.3-1.3-1.4-1.4L4.6 16l3.7 3.7 1.4-1.4L8.4 17h6.9c1.3 0 2.6-.5 3.5-1.5l2.8-2.8-1.3-1.4z" /></SVG> + </> + ) } + /> + </Toolbar> + ); + } } + renderContent={ ( { onClose } ) => ( + <> + { ( hasBlockStyles || possibleBlockTransformations.length !== 0 ) && + <div className="block-editor-block-switcher__container"> + { hasBlockStyles && + <PanelBody + title={ __( 'Block Styles' ) } + initialOpen + > + <BlockStyles + clientId={ blocks[ 0 ].clientId } + onSwitch={ onClose } + onHoverClassName={ this.onHoverClassName } + /> + </PanelBody> + } + { possibleBlockTransformations.length !== 0 && + <PanelBody + title={ __( 'Transform To:' ) } + initialOpen + > + <BlockTypesList + items={ possibleBlockTransformations.map( ( destinationBlockType ) => ( { + id: destinationBlockType.name, + icon: destinationBlockType.icon, + title: destinationBlockType.title, + } ) ) } + onSelect={ ( item ) => { + onTransform( blocks, item.id ); + onClose(); + } } + /> + </PanelBody> + } + </div> + } + { ( hoveredClassName !== null ) && + <div className="block-editor-block-switcher__preview"> + <div className="block-editor-block-switcher__preview-title">{ __( 'Preview' ) }</div> + <BlockPreview + viewportWidth={ 500 } + blocks={ + hoveredBlockType.example ? + getBlockFromExample( hoveredBlock.name, { + attributes: { ...hoveredBlockType.example.attributes, className: hoveredClassName }, + innerBlocks: hoveredBlockType.example.innerBlocks, + } ) : + cloneBlock( hoveredBlock, { className: hoveredClassName } ) + } + /> + </div> + } + </> + ) } + /> + ); + } +} + +export default compose( + withSelect( ( select, { clientIds } ) => { + const { getBlocksByClientId, getBlockRootClientId, getInserterItems } = select( 'core/block-editor' ); + const { getBlockStyles } = select( 'core/blocks' ); + const rootClientId = getBlockRootClientId( first( castArray( clientIds ) ) ); + const blocks = getBlocksByClientId( clientIds ); + const firstBlock = blocks && blocks.length === 1 ? blocks[ 0 ] : null; + const styles = firstBlock && getBlockStyles( firstBlock.name ); + return { + blocks, + inserterItems: getInserterItems( rootClientId ), + hasBlockStyles: styles && styles.length > 0, + }; + } ), + withDispatch( ( dispatch, ownProps ) => ( { + onTransform( blocks, name ) { + dispatch( 'core/block-editor' ).replaceBlocks( + ownProps.clientIds, + switchToBlockType( blocks, name ) + ); + }, + } ) ), +)( BlockSwitcher ); diff --git a/gb-src/packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js b/gb-src/packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js new file mode 100644 index 0000000000000..8adae420dab99 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockSwitcher from './'; + +export function MultiBlocksSwitcher( { isMultiBlockSelection, selectedBlockClientIds } ) { + if ( ! isMultiBlockSelection ) { + return null; + } + return ( + <BlockSwitcher key="switcher" clientIds={ selectedBlockClientIds } /> + ); +} + +export default withSelect( + ( select ) => { + const selectedBlockClientIds = select( 'core/block-editor' ).getMultiSelectedBlockClientIds(); + return { + isMultiBlockSelection: selectedBlockClientIds.length > 1, + selectedBlockClientIds, + }; + } +)( MultiBlocksSwitcher ); diff --git a/gb-src/packages/block-editor/src/components/block-switcher/style.scss b/gb-src/packages/block-editor/src/components/block-switcher/style.scss new file mode 100644 index 0000000000000..c6acd359abd2f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/style.scss @@ -0,0 +1,150 @@ +.block-editor-block-switcher { + position: relative; + height: $icon-button-size; +} + +.components-icon-button.block-editor-block-switcher__toggle, +.components-icon-button.block-editor-block-switcher__no-switcher-icon { + margin: 0; + display: block; + height: $icon-button-size; + padding: 3px; +} + +.components-icon-button.block-editor-block-switcher__no-switcher-icon { + width: $icon-button-size + 6px + 6px; + + .block-editor-block-icon { + margin-right: auto; + margin-left: auto; + } +} + +// When the block switcher does not have any transformations, we show it but as disabled. +// The background and opacity change helps make the icon legible, despite being disabled. +.components-button.block-editor-block-switcher__no-switcher-icon:disabled { + border-radius: 0; + opacity: 0.84; + + // Also make the icon monochrome to further imply disabled state. + // We use !important here because icon colors are set as inline styles, + // and should be overridden when disabled. + .block-editor-block-icon.has-colors { + color: $dark-gray-500 !important; + background: $light-gray-200 !important; + } +} + +// Style this the same as the block buttons in the library. +// Needs specificiity to override the icon button. +.components-icon-button.block-editor-block-switcher__toggle { + width: auto; + // Unset icon button styles. + &:active, + &:not(:disabled):not([aria-disabled="true"]):hover, + &:not([aria-disabled="true"]):focus { + outline: none; + box-shadow: none; + background: none; + border: none; + } + + .block-editor-block-icon, + .block-editor-block-switcher__transform { + width: $icon-button-size + 3px + 3px; + height: $icon-button-size-small + 6px; + position: relative; + margin: 0 auto; + padding: 3px; + display: flex; + align-items: center; + transition: all 0.1s cubic-bezier(0.165, 0.84, 0.44, 1); + @include reduce-motion("transition"); + } + + // Add a dropdown arrow indicator. + .block-editor-block-icon::after { + @include dropdown-arrow(); + } + + .block-editor-block-switcher__transform { + margin-top: 6px; + border-radius: $radius-round-rectangle; + } + + // Block hover and focus style. + &[aria-expanded="true"] .block-editor-block-icon, + &[aria-expanded="true"] .block-editor-block-switcher__transform, + &:not(:disabled):hover .block-editor-block-icon, + &:not(:disabled):hover .block-editor-block-switcher__transform, + &:not(:disabled):focus .block-editor-block-icon, + &:not(:disabled):focus .block-editor-block-switcher__transform { + transform: translateY(-$icon-button-size); + } + + // Block focus style. + &:not(:disabled):focus .block-editor-block-icon, + &:not(:disabled):focus .block-editor-block-switcher__transform { + @include formatting-button-style__focus(); + } +} +// We double the max-width for it to fit both the preview & content but we keep the min width the same for the border to work. +.components-popover:not(.is-mobile).block-editor-block-switcher__popover .components-popover__content { + min-width: 300px; + max-width: calc(340px * 2); + display: flex; + background: $white; + box-shadow: $shadow-popover; +} + +.block-editor-block-switcher__popover .components-popover__content { + .block-editor-block-switcher__container { + min-width: 300px; + max-width: 340px; + width: 50%; + } + + @include break-medium { + position: relative; + + .block-editor-block-switcher__preview { + border-left: $border-width solid $light-gray-500; + box-shadow: $shadow-popover; + background: $white; + width: 300px; + height: auto; + // Sticky helps us keep the preview at the top when scrolling + position: sticky; + align-self: stretch; + // For sticky to work we need top + top: 0; + padding: 10px; + } + } + + // Hide the bottom border on the last panel so it stacks with the popover. + .components-panel__body { + border: 0; + + // Elevate this so the hover style is visible. + position: relative; + z-index: 1; + } + + .components-panel__body + .components-panel__body { + border-top: $border-width solid $light-gray-500; + } +} + +.block-editor-block-switcher__popover .block-editor-block-styles { + margin: 0 -3px; // Remove the panel body padding while keeping it for the title. +} + +.block-editor-block-switcher__popover .block-editor-block-types-list { + margin: 8px -8px -8px; +} + +.block-editor-block-switcher__preview-title { + margin-bottom: 10px; + color: $dark-gray-300; +} diff --git a/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..09a84bb9fbef0 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockSwitcher should render disabled block switcher with multi block of different types when no transforms 1`] = ` +<Toolbar> + <ForwardRef(IconButton) + className="editor-block-switcher__no-switcher-icon block-editor-block-switcher__no-switcher-icon" + disabled={true} + icon={ + <BlockIcon + icon="layout" + showColors={true} + /> + } + label="Block icon" + /> +</Toolbar> +`; + +exports[`BlockSwitcher should render enabled block switcher with multi block when transforms exist 1`] = ` +<Dropdown + className="editor-block-switcher block-editor-block-switcher" + contentClassName="editor-block-switcher__popover block-editor-block-switcher__popover" + position="bottom right" + renderContent={[Function]} + renderToggle={[Function]} +/> +`; + +exports[`BlockSwitcher should render switcher with blocks 1`] = ` +<Dropdown + className="editor-block-switcher block-editor-block-switcher" + contentClassName="editor-block-switcher__popover block-editor-block-switcher__popover" + position="bottom right" + renderContent={[Function]} + renderToggle={[Function]} +/> +`; diff --git a/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap b/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap new file mode 100644 index 0000000000000..a550bb9a06d91 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MultiBlocksSwitcher should return a BlockSwitcher element matching the snapshot. 1`] = ` +<WithSelect(WithDispatch(BlockSwitcher)) + clientIds={ + Array [ + "clientid-1", + "clientid-2", + ] + } + key="switcher" +/> +`; diff --git a/gb-src/packages/block-editor/src/components/block-switcher/test/index.js b/gb-src/packages/block-editor/src/components/block-switcher/test/index.js new file mode 100644 index 0000000000000..544d403872206 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/test/index.js @@ -0,0 +1,200 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; +import { DOWN } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { BlockSwitcher } from '../'; + +describe( 'BlockSwitcher', () => { + const headingBlock1 = { + attributes: { + content: [ 'How are you?' ], + level: 2, + }, + isValid: true, + name: 'core/heading', + originalContent: '<h2>How are you?</h2>', + clientId: 'a1303fd6-3e60-4fff-a770-0e0ea656c5b9', + }; + + const textBlock = { + attributes: { + content: [ 'I am great!' ], + }, + isValid: true, + name: 'core/paragraph', + originalContent: '<p>I am great!</p>', + clientId: 'b1303fdb-3e60-43faf-a770-2e1ea656c5b8', + }; + + const headingBlock2 = { + attributes: { + content: [ 'I am the greatest!' ], + level: 3, + }, + isValid: true, + name: 'core/heading', + originalContent: '<h3>I am the greatest!</h3>', + clientId: 'c2403fd2-4e63-5ffa-b71c-1e0ea656c5b0', + }; + + beforeAll( () => { + registerBlockType( 'core/heading', { + category: 'common', + title: 'Heading', + edit: () => { }, + save: () => {}, + transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: () => {}, + }, + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: () => {}, + isMultiBlock: true, + }, + ], + }, + } ); + + registerBlockType( 'core/paragraph', { + category: 'common', + title: 'Paragraph', + edit: () => { }, + save: () => {}, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/heading' ], + transform: () => {}, + } ], + }, + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/heading' ); + unregisterBlockType( 'core/paragraph' ); + } ); + + test( 'should not render block switcher without blocks', () => { + const wrapper = shallow( <BlockSwitcher /> ); + + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should render switcher with blocks', () => { + const blocks = [ + headingBlock1, + ]; + const inserterItems = [ + { name: 'core/heading', frecency: 1 }, + { name: 'core/paragraph', frecency: 1 }, + ]; + + const wrapper = shallow( <BlockSwitcher blocks={ blocks } inserterItems={ inserterItems } /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should render disabled block switcher with multi block of different types when no transforms', () => { + const blocks = [ headingBlock1, textBlock ]; + const inserterItems = [ + { name: 'core/heading', frecency: 1 }, + { name: 'core/paragraph', frecency: 1 }, + ]; + + const wrapper = shallow( <BlockSwitcher blocks={ blocks } inserterItems={ inserterItems } /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should render enabled block switcher with multi block when transforms exist', () => { + const blocks = [ headingBlock1, headingBlock2 ]; + const inserterItems = [ + { name: 'core/heading', frecency: 1 }, + { name: 'core/paragraph', frecency: 1 }, + ]; + + const wrapper = shallow( <BlockSwitcher blocks={ blocks } inserterItems={ inserterItems } /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + describe( 'Dropdown', () => { + const blocks = [ + headingBlock1, + ]; + + const inserterItems = [ + { name: 'core/quote', frecency: 1 }, + { name: 'core/cover-image', frecency: 2 }, + { name: 'core/paragraph', frecency: 3 }, + { name: 'core/heading', frecency: 4 }, + { name: 'core/text', frecency: 5 }, + ]; + + const onTransformStub = jest.fn(); + const getDropdown = () => { + const blockSwitcher = shallow( <BlockSwitcher blocks={ blocks } onTransform={ onTransformStub } inserterItems={ inserterItems } /> ); + return blockSwitcher.find( 'Dropdown' ); + }; + + test( 'should dropdown exist', () => { + expect( getDropdown() ).toHaveLength( 1 ); + } ); + + describe( '.renderToggle', () => { + const onToggleStub = jest.fn(); + const mockKeyDown = { + preventDefault: () => {}, + stopPropagation: () => {}, + keyCode: DOWN, + }; + + afterEach( () => { + onToggleStub.mockReset(); + } ); + + test( 'should simulate a keydown event, which should call onToggle and open transform toggle.', () => { + const toggleClosed = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: false } ) ); + const iconButtonClosed = toggleClosed.find( 'ForwardRef(IconButton)' ); + + iconButtonClosed.simulate( 'keydown', mockKeyDown ); + + expect( onToggleStub ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'should simulate a click event, which should call onToggle.', () => { + const toggleOpen = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: true } ) ); + const iconButtonOpen = toggleOpen.find( 'ForwardRef(IconButton)' ); + + iconButtonOpen.simulate( 'keydown', mockKeyDown ); + + expect( onToggleStub ).toHaveBeenCalledTimes( 0 ); + } ); + } ); + + describe( '.renderContent', () => { + test( 'should create the transform items for the chosen block. A heading block will have 3 items', () => { + const onCloseStub = jest.fn(); + const content = shallow( <div>{ getDropdown().props().renderContent( { onClose: onCloseStub } ) }</div> ); + const blockList = content.find( 'BlockTypesList' ); + expect( blockList.prop( 'items' ) ).toHaveLength( 1 ); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-switcher/test/multi-blocks-switcher.js b/gb-src/packages/block-editor/src/components/block-switcher/test/multi-blocks-switcher.js new file mode 100644 index 0000000000000..30199f6c59852 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-switcher/test/multi-blocks-switcher.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { MultiBlocksSwitcher } from '../multi-blocks-switcher'; + +describe( 'MultiBlocksSwitcher', () => { + test( 'should return null when the selection is not a multi block selection.', () => { + const isMultiBlockSelection = false; + const selectedBlockClientIds = [ + 'clientid', + ]; + const wrapper = shallow( + <MultiBlocksSwitcher + isMultiBlockSelection={ isMultiBlockSelection } + selectedBlockClientIds={ selectedBlockClientIds } + /> + ); + + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should return a BlockSwitcher element matching the snapshot.', () => { + const isMultiBlockSelection = true; + const selectedBlockClientIds = [ + 'clientid-1', + 'clientid-2', + ]; + const wrapper = shallow( + <MultiBlocksSwitcher + isMultiBlockSelection={ isMultiBlockSelection } + selectedBlockClientIds={ selectedBlockClientIds } + /> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-title/README.md b/gb-src/packages/block-editor/src/components/block-title/README.md new file mode 100644 index 0000000000000..1e22d83dba461 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-title/README.md @@ -0,0 +1,10 @@ +Block Title +=========== + +Renders the block's configured title as a string, or empty if the title cannot be determined. + +## Usage + +```jsx +<BlockTitle clientId="afd1cb17-2c08-4e7a-91be-007ba7ddc3a1" /> +``` diff --git a/gb-src/packages/block-editor/src/components/block-title/index.js b/gb-src/packages/block-editor/src/components/block-title/index.js new file mode 100644 index 0000000000000..6834df1149892 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-title/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { getBlockType } from '@wordpress/blocks'; + +/** + * Renders the block's configured title as a string, or empty if the title + * cannot be determined. + * + * @example + * + * ```jsx + * <BlockTitle clientId="afd1cb17-2c08-4e7a-91be-007ba7ddc3a1" /> + * ``` + * + * @param {Object} props + * @param {?string} props.name Block name. + * + * @return {?string} Block title. + */ +export function BlockTitle( { name } ) { + if ( ! name ) { + return null; + } + + const blockType = getBlockType( name ); + if ( ! blockType ) { + return null; + } + + return blockType.title; +} + +export default withSelect( ( select, ownProps ) => { + const { getBlockName } = select( 'core/block-editor' ); + const { clientId } = ownProps; + + return { + name: getBlockName( clientId ), + }; +} )( BlockTitle ); diff --git a/gb-src/packages/block-editor/src/components/block-title/test/index.js b/gb-src/packages/block-editor/src/components/block-title/test/index.js new file mode 100644 index 0000000000000..4306ae6247b93 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-title/test/index.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { BlockTitle } from '../'; + +jest.mock( '@wordpress/blocks', () => { + return { + getBlockType( name ) { + switch ( name ) { + case 'name-not-exists': + return null; + + case 'name-exists': + return { title: 'Block Title' }; + } + }, + }; +} ); + +describe( 'BlockTitle', () => { + it( 'renders nothing if name is falsey', () => { + const wrapper = shallow( <BlockTitle /> ); + + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'renders nothing if block type does not exist', () => { + const wrapper = shallow( <BlockTitle name="name-not-exists" /> ); + + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'renders title if block type exists', () => { + const wrapper = shallow( <BlockTitle name="name-exists" /> ); + + expect( wrapper.text() ).toBe( 'Block Title' ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/block-toolbar/index.js b/gb-src/packages/block-editor/src/components/block-toolbar/index.js new file mode 100644 index 0000000000000..83edd7814a0ed --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-toolbar/index.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockSwitcher from '../block-switcher'; +import MultiBlocksSwitcher from '../block-switcher/multi-blocks-switcher'; +import BlockControls from '../block-controls'; +import BlockFormatControls from '../block-format-controls'; +import BlockSettingsMenu from '../block-settings-menu'; + +function BlockToolbar( { blockClientIds, isValid, mode } ) { + if ( blockClientIds.length === 0 ) { + return null; + } + + if ( blockClientIds.length > 1 ) { + return ( + <div className="editor-block-toolbar block-editor-block-toolbar"> + <MultiBlocksSwitcher /> + <BlockSettingsMenu clientIds={ blockClientIds } /> + </div> + ); + } + + return ( + <div className="editor-block-toolbar block-editor-block-toolbar"> + { mode === 'visual' && isValid && ( + <> + <BlockSwitcher clientIds={ blockClientIds } /> + <BlockControls.Slot bubblesVirtually className="block-editor-block-toolbar__slot" /> + <BlockFormatControls.Slot bubblesVirtually className="block-editor-block-toolbar__slot" /> + </> + ) } + <BlockSettingsMenu clientIds={ blockClientIds } /> + </div> + ); +} + +export default withSelect( ( select ) => { + const { + getBlockMode, + getSelectedBlockClientIds, + isBlockValid, + } = select( 'core/block-editor' ); + const blockClientIds = getSelectedBlockClientIds(); + + return { + blockClientIds, + isValid: blockClientIds.length === 1 ? isBlockValid( blockClientIds[ 0 ] ) : null, + mode: blockClientIds.length === 1 ? getBlockMode( blockClientIds[ 0 ] ) : null, + }; +} )( BlockToolbar ); diff --git a/gb-src/packages/block-editor/src/components/block-toolbar/index.native.js b/gb-src/packages/block-editor/src/components/block-toolbar/index.native.js new file mode 100644 index 0000000000000..5413e4e1f9cde --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-toolbar/index.native.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +/** + * WordPress dependencies + */ +import { BlockFormatControls, BlockControls } from '@wordpress/block-editor'; + +export const BlockToolbar = ( { blockClientIds, isValid, mode } ) => { + if ( blockClientIds.length === 0 ) { + return null; + } + + return ( + <> + { mode === 'visual' && isValid && ( + <> + <BlockControls.Slot /> + <BlockFormatControls.Slot /> + </> + ) } + </> + ); +}; + +export default withSelect( ( select ) => { + const { + getBlockMode, + getSelectedBlockClientIds, + isBlockValid, + } = select( 'core/block-editor' ); + const blockClientIds = getSelectedBlockClientIds(); + + return { + blockClientIds, + isValid: blockClientIds.length === 1 ? isBlockValid( blockClientIds[ 0 ] ) : null, + mode: blockClientIds.length === 1 ? getBlockMode( blockClientIds[ 0 ] ) : null, + }; +} )( BlockToolbar ); diff --git a/gb-src/packages/block-editor/src/components/block-toolbar/style.scss b/gb-src/packages/block-editor/src/components/block-toolbar/style.scss new file mode 100644 index 0000000000000..e83f34bc54ead --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-toolbar/style.scss @@ -0,0 +1,57 @@ +.block-editor-block-toolbar { + display: flex; + flex-grow: 1; + width: 100%; + overflow: auto; // Allow horizontal scrolling on mobile. + position: relative; + border-left: $border-width solid $light-gray-800; + transition: border-color 0.1s linear, box-shadow 0.1s linear; + @include reduce-motion("transition"); + + @include break-small() { + // Allow overflow on desktop. + overflow: inherit; + + // Show a left border on the parent container. + border-left: none; + box-shadow: -$block-left-border-width 0 0 0 $dark-gray-500; + + // Show a lighter version for dark themes. + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $light-gray-600; + } + } + + // The component is born with a border, but we only need some of them. + .components-toolbar { + border: 0; + border-top: $border-width solid $light-gray-800; + border-bottom: $border-width solid $light-gray-800; + + // Add a right border to show as separator in the block toolbar. + border-right: $border-width solid $light-gray-800; + + // IE11 has thick paddings without this. + line-height: 0; + } + + // Add a left border and adjust the color for Top Toolbar mode. + .has-fixed-toolbar & { + box-shadow: none; + border-left: $border-width solid $light-gray-500; + + .components-toolbar { + border-color: $light-gray-500; + } + } +} + +.block-editor-block-toolbar__slot { + // Required for IE11. + display: inline-block; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + display: inline-flex; + } +} diff --git a/gb-src/packages/block-editor/src/components/block-types-list/index.js b/gb-src/packages/block-editor/src/components/block-types-list/index.js new file mode 100644 index 0000000000000..8c2de6772dd8e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-types-list/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { getBlockMenuDefaultClassName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import InserterListItem from '../inserter-list-item'; + +function BlockTypesList( { items, onSelect, onHover = () => {}, children } ) { + return ( + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ + <ul role="list" className="editor-block-types-list block-editor-block-types-list"> + { items && items.map( ( item ) => + <InserterListItem + key={ item.id } + className={ getBlockMenuDefaultClassName( item.id ) } + icon={ item.icon } + onClick={ () => { + onSelect( item ); + onHover( null ); + } } + onFocus={ () => onHover( item ) } + onMouseEnter={ () => onHover( item ) } + onMouseLeave={ () => onHover( null ) } + onBlur={ () => onHover( null ) } + isDisabled={ item.isDisabled } + title={ item.title } + /> + ) } + { children } + </ul> + /* eslint-enable jsx-a11y/no-redundant-roles */ + ); +} + +export default BlockTypesList; diff --git a/gb-src/packages/block-editor/src/components/block-types-list/style.scss b/gb-src/packages/block-editor/src/components/block-types-list/style.scss new file mode 100644 index 0000000000000..1f197db18126b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-types-list/style.scss @@ -0,0 +1,9 @@ +.block-editor-block-types-list { + list-style: none; + padding: 4px; + margin-left: -4px; + margin-right: -4px; + overflow: hidden; + display: flex; + flex-wrap: wrap; +} diff --git a/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/README.md b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/README.md new file mode 100644 index 0000000000000..3e11032e85fa5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/README.md @@ -0,0 +1,88 @@ +BlockVerticalAlignmentToolbar +============================= + +`BlockVerticalAlignmentToolbar` is a simple `Toolbar` component designed to provide _vertical_ alignment UI controls for use within the editor `BlockControls` toolbar. + +This builds upon similar patterns to the [`BlockAlignmentToolbar`](https://github.com/WordPress/gutenberg/tree/master/packages/editor/src/components/block-alignment-toolbar) but is focused on vertical alignment only. + +## Usage + +In a block's `edit` implementation, render a `<BlockControls />` component. Then inside of this add the `<BlockVerticalAlignmentToolbar />` where required. + + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { + BlockControls, + BlockVerticalAlignmentToolbar, +} from '@wordpress/block-editor'; + +registerBlockType( 'my-plugin/my-block', { + // ... + + attributes: { + // other attributes here + // ... + + verticalAlignment: { + type: 'string', + }, + }, + + edit( { attributes, setAttributes } ) { + + const { verticalAlignment } = attributes; + + // Change handler to set Block `attributes` + const onChange = ( alignment ) => setAttributes( { verticalAlignment: alignment } ); + + return ( + <> + <BlockControls> + <BlockVerticalAlignmentToolbar + onChange={ onChange } + value={ verticalAlignment } + /> + </BlockControls> + <div> + // your Block here + </div> + </> + ); + } +} ); +``` + +_Note:_ by default if you do not provide an initial `value` prop for the current alignment value, then no value will be selected (ie: there is no default alignment set). + +_Note:_ the user can repeatedly click on the toolbar buttons to toggle the alignment values on/off. This is handled within the component. + +## Props + +### `value` +* **Type:** `String` +* **Default:** `undefined` +* **Options:**: `top`, `center`, `bottom` + +The current value of the alignment setting. You may only choose from the `Options` listed above. + +### `isCollapsed` +* **Type:** `Boolean` +* **Default:** `true` + +Whether to display toolbar options in the dropdown menu. This toolbar is collapsed by default. + +### `onChange` +* **Type:** `Function` + +A callback function invoked when the toolbar's alignment value is changed via an interaction with any of the toolbar's buttons. Called with the new alignment value (ie: `top`, `center`, `bottom`, `undefined`) as the only argument. + +Note: the value may be `undefined` if the user has toggled the component "off". + +```js +const onChange = ( alignment ) => setAttributes( { verticalAlignment: alignment } ); +``` + +## Examples + +The [Core Columns](https://github.com/WordPress/gutenberg/tree/master/packages/block-library/src/columns) Block utilises the `BlockVerticalAlignmentToolbar`. diff --git a/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/icons.js b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/icons.js new file mode 100644 index 0000000000000..4642eddb367c7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/icons.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export const alignBottom = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M16 13h-3V3h-2v10H8l4 4 4-4zM4 19v2h16v-2H4z" /> + </SVG> +); + +export const alignCenter = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M8 19h3v4h2v-4h3l-4-4-4 4zm8-14h-3V1h-2v4H8l4 4 4-4zM4 11v2h16v-2H4z" + /> + </SVG> +); + +export const alignTop = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z" /> + </SVG> +); diff --git a/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js new file mode 100644 index 0000000000000..22206984dc270 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { alignTop, alignCenter, alignBottom } from './icons'; + +const BLOCK_ALIGNMENTS_CONTROLS = { + top: { + icon: alignTop, + title: _x( 'Vertically Align Top', 'Block vertical alignment setting' ), + }, + center: { + icon: alignCenter, + title: _x( 'Vertically Align Middle', 'Block vertical alignment setting' ), + }, + bottom: { + icon: alignBottom, + title: _x( 'Vertically Align Bottom', 'Block vertical alignment setting' ), + }, +}; + +const DEFAULT_CONTROLS = [ 'top', 'center', 'bottom' ]; +const DEFAULT_CONTROL = 'top'; + +export function BlockVerticalAlignmentToolbar( { value, onChange, controls = DEFAULT_CONTROLS, isCollapsed = true } ) { + function applyOrUnset( align ) { + return () => onChange( value === align ? undefined : align ); + } + + const activeAlignment = BLOCK_ALIGNMENTS_CONTROLS[ value ]; + const defaultAlignmentControl = BLOCK_ALIGNMENTS_CONTROLS[ DEFAULT_CONTROL ]; + + return ( + <Toolbar + isCollapsed={ isCollapsed } + icon={ activeAlignment ? activeAlignment.icon : defaultAlignmentControl.icon } + label={ _x( 'Change vertical alignment', 'Block vertical alignment setting label' ) } + controls={ + controls.map( ( control ) => { + return { + ...BLOCK_ALIGNMENTS_CONTROLS[ control ], + isActive: value === control, + onClick: applyOrUnset( control ), + }; + } ) + } + /> + ); +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/block-vertical-alignment-toolbar/README.md + */ +export default BlockVerticalAlignmentToolbar; diff --git a/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..605e697786030 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/__snapshots__/index.js.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockVerticalAlignmentToolbar should match snapshot 1`] = ` +<Toolbar + controls={ + Array [ + Object { + "icon": <SVG + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <Path + d="M0 0h24v24H0V0z" + fill="none" + /> + <Path + d="M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z" + /> + </SVG>, + "isActive": true, + "onClick": [Function], + "title": "Vertically Align Top", + }, + Object { + "icon": <SVG + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <Path + d="M0 0h24v24H0V0z" + fill="none" + /> + <Path + d="M8 19h3v4h2v-4h3l-4-4-4 4zm8-14h-3V1h-2v4H8l4 4 4-4zM4 11v2h16v-2H4z" + /> + </SVG>, + "isActive": false, + "onClick": [Function], + "title": "Vertically Align Middle", + }, + Object { + "icon": <SVG + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <Path + d="M0 0h24v24H0V0z" + fill="none" + /> + <Path + d="M16 13h-3V3h-2v10H8l4 4 4-4zM4 19v2h16v-2H4z" + /> + </SVG>, + "isActive": false, + "onClick": [Function], + "title": "Vertically Align Bottom", + }, + ] + } + icon={ + <SVG + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <Path + d="M0 0h24v24H0V0z" + fill="none" + /> + <Path + d="M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z" + /> + </SVG> + } + isCollapsed={true} + label="Change vertical alignment" +/> +`; diff --git a/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/index.js b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/index.js new file mode 100644 index 0000000000000..6840e35ba6331 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/block-vertical-alignment-toolbar/test/index.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { BlockVerticalAlignmentToolbar } from '../'; + +describe( 'BlockVerticalAlignmentToolbar', () => { + const alignment = 'top'; + const onChange = jest.fn(); + + const wrapper = shallow( <BlockVerticalAlignmentToolbar value={ alignment } onChange={ onChange } /> ); + + const controls = wrapper.props().controls; + + afterEach( () => { + onChange.mockClear(); + } ); + + it( 'should match snapshot', () => { + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should call onChange with undefined, when the control is already active', () => { + const activeControl = controls.find( ( { title } ) => title.toLowerCase().includes( alignment ) ); + activeControl.onClick(); + + expect( activeControl.isActive ).toBe( true ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( undefined ); + } ); + + it( 'should call onChange with alignment value when the control is inactive', () => { + // note "middle" alias for "center" + const inactiveCenterControl = controls.find( ( { title } ) => title.toLowerCase().includes( 'middle' ) ); + + inactiveCenterControl.onClick(); + + expect( inactiveCenterControl.isActive ).toBe( false ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( 'center' ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/button-block-appender/README.md b/gb-src/packages/block-editor/src/components/button-block-appender/README.md new file mode 100644 index 0000000000000..87d917ab50b58 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/button-block-appender/README.md @@ -0,0 +1,43 @@ +ButtonBlockAppender +============================= + +`ButtonBlockAppender` provides button with a `+` (plus) icon which when clicked will trigger the default Block `Inserter` UI to allow a Block to be inserted. + +This is typically used as an alternative to the `<DefaultBlockAppender />` component to determine the initial placeholder behaviour for a Block when displayed in the editor UI. + +## Usage + +In a block's `edit` implementation, render a `<ButtonBlockAppender />` component passing in the `rootClientId`. + + +```jsx +function render( { clientId }) { + return ( + <div> + <p>Some rendered content here</p> + <ButtonBlockAppender rootClientId={ clientId } /> + </div> + ); +} +``` + +_Note:_ + +## Props + +### `rootClientId` +* **Type:** `String` +* **Required** `true` +* **Default:** `undefined` + +The `clientId` of the Block from who's root new Blocks should be inserted. This prop is required by the block `Inserter` component. Typically this is the `clientID` of the Block where the prop is being rendered. + +### `className` +* **Type:** `String` +* **Default:** `""` + +A CSS `class` to be _prepended_ to the default class of `"button-block-appender"`. + +## Examples + +The [`<InnerBlocks>` component](../inner-blocks/) exposes an enhanced version of `ButtonBlockAppender` to allow consumers to choose it as an alternative to the standard behaviour of auto-inserting the default Block (typically `core/paragraph`). diff --git a/gb-src/packages/block-editor/src/components/button-block-appender/index.js b/gb-src/packages/block-editor/src/components/button-block-appender/index.js new file mode 100644 index 0000000000000..7fd751165f5b4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/button-block-appender/index.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Button, Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BlockDropZone from '../block-drop-zone'; +import Inserter from '../inserter'; + +function ButtonBlockAppender( { rootClientId, className } ) { + return ( + <> + <BlockDropZone rootClientId={ rootClientId } /> + <Inserter + rootClientId={ rootClientId } + renderToggle={ ( { onToggle, disabled, isOpen } ) => ( + <Button + className={ classnames( className, 'block-editor-button-block-appender' ) } + onClick={ onToggle } + aria-expanded={ isOpen } + disabled={ disabled } + > + <span className="screen-reader-text">{ __( 'Add Block' ) }</span> + <Icon icon="insert" /> + </Button> + ) } + isAppender + /> + </> + ); +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/button-block-appender/README.md + */ +export default ButtonBlockAppender; diff --git a/gb-src/packages/block-editor/src/components/button-block-appender/style.scss b/gb-src/packages/block-editor/src/components/button-block-appender/style.scss new file mode 100644 index 0000000000000..f153a2353f9b4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/button-block-appender/style.scss @@ -0,0 +1,33 @@ +.block-editor-button-block-appender { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $block-padding*1.5; + outline: $border-width dashed $dark-gray-150; + width: 100%; + color: $dark-gray-500; + background: $light-opacity-background-fill; + + &:hover, + &:focus { + outline: $border-width dashed $dark-gray-500; + color: $dark-gray-900; + } + + &:active { + outline: $border-width dashed $dark-gray-900; + color: $dark-gray-900; + } + + // Use opacity to work in various editor styles + .is-dark-theme & { + background: $dark-opacity-background-fill; + color: $light-gray-100; + + &:hover, + &:focus { + outline: $border-width dashed $white; + } + } +} diff --git a/gb-src/packages/block-editor/src/components/caption/index.native.js b/gb-src/packages/block-editor/src/components/caption/index.native.js new file mode 100644 index 0000000000000..68e965524c8b8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/caption/index.native.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; +import { compose } from '@wordpress/compose'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +const Caption = ( { accessible, accessibilityLabel, onBlur, onChange, onFocus, isSelected, shouldDisplay, text } ) => ( + <View style={ { padding: 12, flex: 1, display: shouldDisplay ? 'flex' : 'none' } } + accessible={ accessible } + accessibilityLabel={ accessibilityLabel } + accessibilityRole={ 'button' } + > + <RichText + rootTagsToEliminate={ [ 'p' ] } + placeholder={ __( 'Write caption…' ) } + value={ text } + onChange={ onChange } + unstableOnFocus={ onFocus } + onBlur={ onBlur } // always assign onBlur as props + isSelected={ isSelected } + __unstableMobileNoFocusOnMount + fontSize={ 14 } + underlineColorAndroid="transparent" + textAlign={ 'center' } + tagName={ '' } + /> + </View> +); + +export default compose( [ + withSelect( ( select, { accessibilityLabelCreator, clientId } ) => { + const { + getBlockAttributes, + getSelectedBlockClientId, + } = select( 'core/block-editor' ); + const { caption } = getBlockAttributes( clientId ); + const accessibilityLabel = accessibilityLabelCreator ? accessibilityLabelCreator( caption ) : undefined; + const isBlockSelected = getSelectedBlockClientId() === clientId; + + // We'll render the caption so that the soft keyboard is not forced to close on Android + // but still hide it by setting its display style to none. See wordpress-mobile/gutenberg-mobile#1221 + const shouldDisplay = ! RichText.isEmpty( caption ) > 0 || isBlockSelected; + + return { + accessibilityLabel, + shouldDisplay, + text: caption, + }; + } ), + withDispatch( ( dispatch, { clientId } ) => { + const { updateBlockAttributes } = dispatch( 'core/block-editor' ); + return { + onChange: ( caption ) => { + updateBlockAttributes( clientId, { caption } ); + }, + }; + } ), +] )( Caption ); diff --git a/gb-src/packages/block-editor/src/components/color-palette/control.js b/gb-src/packages/block-editor/src/components/color-palette/control.js new file mode 100644 index 0000000000000..6a2da23b527c6 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/control.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { BaseControl, ColorIndicator } from '@wordpress/components'; +import { ifCondition, compose } from '@wordpress/compose'; +import { sprintf, __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ColorPalette from './'; +import withColorContext from './with-color-context'; +import { getColorObjectByColorValue } from '../colors'; + +// translators: first %s: The type of color (e.g. background color), second %s: the color name or value (e.g. red or #ff0000) +const colorIndicatorAriaLabel = __( '(current %s: %s)' ); + +export function ColorPaletteControl( { + colors, + disableCustomColors, + label, + onChange, + value, +} ) { + const colorObject = getColorObjectByColorValue( colors, value ); + const colorName = colorObject && colorObject.name; + const ariaLabel = sprintf( colorIndicatorAriaLabel, label.toLowerCase(), colorName || value ); + + return ( + <BaseControl + className="editor-color-palette-control block-editor-color-palette-control" + > + <BaseControl.VisualLabel> + { label } + { value && ( + <ColorIndicator + colorValue={ value } + aria-label={ ariaLabel } + /> + ) } + </BaseControl.VisualLabel> + <ColorPalette + className="editor-color-palette-control__color-palette block-editor-color-palette-control__color-palette" + value={ value } + onChange={ onChange } + { ... { colors, disableCustomColors } } + /> + </BaseControl> + ); +} + +export default compose( [ + withColorContext, + ifCondition( ( { hasColorsToChoose } ) => hasColorsToChoose ), +] )( ColorPaletteControl ); diff --git a/gb-src/packages/block-editor/src/components/color-palette/control.scss b/gb-src/packages/block-editor/src/components/color-palette/control.scss new file mode 100644 index 0000000000000..249fc32607be1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/control.scss @@ -0,0 +1,4 @@ +.block-editor-color-palette-control__color-palette { + display: inline-block; + margin-top: 0.6rem; +} diff --git a/gb-src/packages/block-editor/src/components/color-palette/index.js b/gb-src/packages/block-editor/src/components/color-palette/index.js new file mode 100644 index 0000000000000..1601ea4f08ec3 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { ColorPalette } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import withColorContext from './with-color-context'; + +export default withColorContext( ColorPalette ); diff --git a/gb-src/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap b/gb-src/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap new file mode 100644 index 0000000000000..e49d5f33ee4e3 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/test/__snapshots__/control.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColorPaletteControl matches the snapshot 1`] = ` +<BaseControl + className="editor-color-palette-control block-editor-color-palette-control" +> + <Component> + Test Color + <ColorIndicator + aria-label="(current test color: red)" + colorValue="#f00" + /> + </Component> + <WithColorContext(ColorPalette) + className="editor-color-palette-control__color-palette block-editor-color-palette-control__color-palette" + colors={ + Array [ + Object { + "color": "#f00", + "name": "red", + }, + ] + } + onChange={[Function]} + value="#f00" + /> +</BaseControl> +`; diff --git a/gb-src/packages/block-editor/src/components/color-palette/test/control.js b/gb-src/packages/block-editor/src/components/color-palette/test/control.js new file mode 100644 index 0000000000000..9d43713fb48e7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/test/control.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { ColorPaletteControl } from '../control'; + +describe( 'ColorPaletteControl', () => { + it( 'matches the snapshot', () => { + const wrapper = shallow( + <ColorPaletteControl + label="Test Color" + value="#f00" + colors={ [ { color: '#f00', name: 'red' } ] } + onChange={ noop } + /> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/color-palette/with-color-context.js b/gb-src/packages/block-editor/src/components/color-palette/with-color-context.js new file mode 100644 index 0000000000000..1c32141f2a3f1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/color-palette/with-color-context.js @@ -0,0 +1,30 @@ + +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +export default createHigherOrderComponent( + withSelect( + ( select, ownProps ) => { + const settings = select( 'core/block-editor' ).getSettings(); + const colors = ownProps.colors === undefined ? + settings.colors : ownProps.colors; + + const disableCustomColors = ownProps.disableCustomColors === undefined ? + settings.disableCustomColors : ownProps.disableCustomColors; + return { + colors, + disableCustomColors, + hasColorsToChoose: ! isEmpty( colors ) || ! disableCustomColors, + }; + } + ), + 'withColorContext' +); diff --git a/gb-src/packages/block-editor/src/components/colors/index.js b/gb-src/packages/block-editor/src/components/colors/index.js new file mode 100644 index 0000000000000..f6b6fac984db8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/index.js @@ -0,0 +1,9 @@ +export { + getColorClassName, + getColorObjectByAttributeValues, + getColorObjectByColorValue, +} from './utils'; +export { + createCustomColorsHOC, + default as withColors, +} from './with-colors'; diff --git a/gb-src/packages/block-editor/src/components/colors/test/__snapshots__/with-colors.js.snap b/gb-src/packages/block-editor/src/components/colors/test/__snapshots__/with-colors.js.snap new file mode 100644 index 0000000000000..fb33dd8d00235 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/test/__snapshots__/with-colors.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createCustomColorsHOC provides the the wrapped component with color values and setter functions as props 1`] = ` +<Component + attributes={ + Object { + "backgroundColor": null, + } + } + backgroundColor={ + Object { + "class": undefined, + "color": undefined, + } + } + colorUtils={ + Object { + "getMostReadableColor": [Function], + } + } + setBackgroundColor={[Function]} +/> +`; diff --git a/gb-src/packages/block-editor/src/components/colors/test/utils.js b/gb-src/packages/block-editor/src/components/colors/test/utils.js new file mode 100644 index 0000000000000..8eb370394aae8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/test/utils.js @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import { + getColorObjectByAttributeValues, + getColorObjectByColorValue, + getColorClassName, +} from '../utils'; + +describe( 'color utils', () => { + describe( 'getColorObjectByAttributeValues', () => { + it( 'should return the custom color object when there is no definedColor', () => { + const colors = [ + { slug: 'red' }, + { slug: 'green' }, + { slug: 'blue' }, + ]; + const customColor = '#ffffff'; + + expect( getColorObjectByAttributeValues( colors, undefined, customColor ) ).toEqual( { color: customColor } ); + } ); + + it( 'should return the custom color object when definedColor was not found', () => { + const colors = [ + { slug: 'red' }, + { slug: 'green' }, + { slug: 'blue' }, + ]; + const definedColor = 'purple'; + const customColor = '#ffffff'; + + expect( getColorObjectByAttributeValues( colors, definedColor, customColor ) ).toEqual( { color: customColor } ); + } ); + + it( 'should return the found color object', () => { + const colors = [ + { slug: 'red' }, + { slug: 'green' }, + { slug: 'blue' }, + ]; + const definedColor = 'blue'; + const customColor = '#ffffff'; + + expect( getColorObjectByAttributeValues( colors, definedColor, customColor ) ).toEqual( { slug: 'blue' } ); + } ); + } ); + + describe( 'getColorObjectByColorValue', () => { + it( 'should return undefined if the given color was not found', () => { + const colors = [ + { slug: 'red', color: '#ff0000' }, + { slug: 'green', color: '#00ff00' }, + { slug: 'blue', color: '#0000ff' }, + ]; + + expect( getColorObjectByColorValue( colors, '#ffffff' ) ).toBeUndefined(); + } ); + + it( 'should return a color object for the given color value', () => { + const colors = [ + { slug: 'red', color: '#ff0000' }, + { slug: 'green', color: '#00ff00' }, + { slug: 'blue', color: '#0000ff' }, + ]; + + expect( getColorObjectByColorValue( colors, '#00ff00' ) ).toEqual( { slug: 'green', color: '#00ff00' } ); + } ); + } ); + + describe( 'getColorClassName', () => { + it( 'should return undefined if colorContextName is missing', () => { + expect( getColorClassName( undefined, 'Light Purple' ) ).toBeUndefined(); + } ); + + it( 'should return undefined if colorSlug is missing', () => { + expect( getColorClassName( 'background', undefined ) ).toBeUndefined(); + } ); + + it( 'should return a class name with the color slug in kebab case', () => { + expect( getColorClassName( 'background', 'Light Purple' ) ).toBe( 'has-light-purple-background' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/colors/test/with-colors.js b/gb-src/packages/block-editor/src/components/colors/test/with-colors.js new file mode 100644 index 0000000000000..07c19ce0c11e7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/test/with-colors.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { shallow, mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { createCustomColorsHOC } from '../with-colors'; + +describe( 'createCustomColorsHOC', () => { + it( 'provides the the wrapped component with color values and setter functions as props', () => { + const withCustomColors = createCustomColorsHOC( [ { name: 'Red', slug: 'red', color: 'ff0000' } ] ); + const EnhancedComponent = withCustomColors( 'backgroundColor' )( () => ( + <div /> + ) ); + + const wrapper = shallow( + <EnhancedComponent attributes={ { backgroundColor: null } } /> + ); + + expect( wrapper.dive() ).toMatchSnapshot(); + } ); + + it( 'setting the color to a value in the provided custom color array updated the backgroundColor attribute', () => { + const withCustomColors = createCustomColorsHOC( [ { name: 'Red', slug: 'red', color: 'ff0000' } ] ); + const EnhancedComponent = withCustomColors( 'backgroundColor' )( ( props ) => ( + <button onClick={ () => props.setBackgroundColor( 'ff0000' ) }>Test Me</button> + ) ); + + const setAttributes = jest.fn(); + + const wrapper = mount( + <EnhancedComponent attributes={ { backgroundColor: null } } setAttributes={ setAttributes } /> + ); + + wrapper.find( 'button' ).simulate( 'click' ); + expect( setAttributes ).toHaveBeenCalledWith( { backgroundColor: 'red', customBackgroundColor: undefined } ); + } ); + + it( 'setting the color to a value not in the provided custom color array updates customBackgroundColor attribute', () => { + const withCustomColors = createCustomColorsHOC( [ { name: 'Red', slug: 'red', color: 'ff0000' } ] ); + const EnhancedComponent = withCustomColors( 'backgroundColor' )( ( props ) => ( + <button onClick={ () => props.setBackgroundColor( '000000' ) }>Test Me</button> + ) ); + + const setAttributes = jest.fn(); + + const wrapper = mount( + <EnhancedComponent attributes={ { backgroundColor: null } } setAttributes={ setAttributes } /> + ); + + wrapper.find( 'button' ).simulate( 'click' ); + expect( setAttributes ).toHaveBeenCalledWith( { backgroundColor: undefined, customBackgroundColor: '000000' } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/colors/utils.js b/gb-src/packages/block-editor/src/components/colors/utils.js new file mode 100644 index 0000000000000..764b02acde282 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/utils.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { find, kebabCase, map } from 'lodash'; +import tinycolor from 'tinycolor2'; + +/** + * Provided an array of color objects as set by the theme or by the editor defaults, + * and the values of the defined color or custom color returns a color object describing the color. + * + * @param {Array} colors Array of color objects as set by the theme or by the editor defaults. + * @param {?string} definedColor A string containing the color slug. + * @param {?string} customColor A string containing the customColor value. + * + * @return {?Object} If definedColor is passed and the name is found in colors, + * the color object exactly as set by the theme or editor defaults is returned. + * Otherwise, an object that just sets the color is defined. + */ +export const getColorObjectByAttributeValues = ( colors, definedColor, customColor ) => { + if ( definedColor ) { + const colorObj = find( colors, { slug: definedColor } ); + + if ( colorObj ) { + return colorObj; + } + } + return { + color: customColor, + }; +}; + +/** + * Provided an array of color objects as set by the theme or by the editor defaults, and a color value returns the color object matching that value or undefined. + * + * @param {Array} colors Array of color objects as set by the theme or by the editor defaults. + * @param {?string} colorValue A string containing the color value. + * + * @return {?Object} Color object included in the colors array whose color property equals colorValue. + * Returns undefined if no color object matches this requirement. + */ +export const getColorObjectByColorValue = ( colors, colorValue ) => { + return find( colors, { color: colorValue } ); +}; + +/** + * Returns a class based on the context a color is being used and its slug. + * + * @param {string} colorContextName Context/place where color is being used e.g: background, text etc... + * @param {string} colorSlug Slug of the color. + * + * @return {?string} String with the class corresponding to the color in the provided context. + * Returns undefined if either colorContextName or colorSlug are not provided. + */ +export function getColorClassName( colorContextName, colorSlug ) { + if ( ! colorContextName || ! colorSlug ) { + return undefined; + } + + return `has-${ kebabCase( colorSlug ) }-${ colorContextName }`; +} + +/** + * Given an array of color objects and a color value returns the color value of the most readable color in the array. + * + * @param {Array} colors Array of color objects as set by the theme or by the editor defaults. + * @param {?string} colorValue A string containing the color value. + * + * @return {string} String with the color value of the most readable color. + */ +export function getMostReadableColor( colors, colorValue ) { + return tinycolor.mostReadable( + colorValue, + map( colors, 'color' ) + ).toHexString(); +} diff --git a/gb-src/packages/block-editor/src/components/colors/with-colors.js b/gb-src/packages/block-editor/src/components/colors/with-colors.js new file mode 100644 index 0000000000000..b6f33b1b8590f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/colors/with-colors.js @@ -0,0 +1,203 @@ +/** + * External dependencies + */ +import { get, isString, kebabCase, reduce, upperFirst } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { compose, createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { getColorClassName, getColorObjectByColorValue, getColorObjectByAttributeValues, getMostReadableColor } from './utils'; + +const DEFAULT_COLORS = []; + +/** + * Higher order component factory for injecting the `colorsArray` argument as + * the colors prop in the `withCustomColors` HOC. + * + * @param {Array} colorsArray An array of color objects. + * + * @return {Function} The higher order component. + */ +const withCustomColorPalette = ( colorsArray ) => createHigherOrderComponent( ( WrappedComponent ) => ( props ) => ( + <WrappedComponent { ...props } colors={ colorsArray } /> +), 'withCustomColorPalette' ); + +/** + * Higher order component factory for injecting the editor colors as the + * `colors` prop in the `withColors` HOC. + * + * @return {Function} The higher order component. + */ +const withEditorColorPalette = () => withSelect( ( select ) => { + const settings = select( 'core/block-editor' ).getSettings(); + return { + colors: get( settings, [ 'colors' ], DEFAULT_COLORS ), + }; +} ); + +/** + * Helper function used with `createHigherOrderComponent` to create + * higher order components for managing color logic. + * + * @param {Array} colorTypes An array of color types (e.g. 'backgroundColor, borderColor). + * @param {Function} withColorPalette A HOC for injecting the 'colors' prop into the WrappedComponent. + * + * @return {Component} The component that can be used as a HOC. + */ +function createColorHOC( colorTypes, withColorPalette ) { + const colorMap = reduce( colorTypes, ( colorObject, colorType ) => { + return { + ...colorObject, + ...( isString( colorType ) ? { [ colorType ]: kebabCase( colorType ) } : colorType ), + }; + }, {} ); + + return compose( [ + withColorPalette, + ( WrappedComponent ) => { + return class extends Component { + constructor( props ) { + super( props ); + + this.setters = this.createSetters(); + this.colorUtils = { + getMostReadableColor: this.getMostReadableColor.bind( this ), + }; + + this.state = {}; + } + + getMostReadableColor( colorValue ) { + const { colors } = this.props; + return getMostReadableColor( colors, colorValue ); + } + + createSetters() { + return reduce( colorMap, ( settersAccumulator, colorContext, colorAttributeName ) => { + const upperFirstColorAttributeName = upperFirst( colorAttributeName ); + const customColorAttributeName = `custom${ upperFirstColorAttributeName }`; + settersAccumulator[ `set${ upperFirstColorAttributeName }` ] = + this.createSetColor( colorAttributeName, customColorAttributeName ); + return settersAccumulator; + }, {} ); + } + + createSetColor( colorAttributeName, customColorAttributeName ) { + return ( colorValue ) => { + const colorObject = getColorObjectByColorValue( this.props.colors, colorValue ); + this.props.setAttributes( { + [ colorAttributeName ]: colorObject && colorObject.slug ? colorObject.slug : undefined, + [ customColorAttributeName ]: colorObject && colorObject.slug ? undefined : colorValue, + } ); + }; + } + + static getDerivedStateFromProps( { attributes, colors }, previousState ) { + return reduce( colorMap, ( newState, colorContext, colorAttributeName ) => { + const colorObject = getColorObjectByAttributeValues( + colors, + attributes[ colorAttributeName ], + attributes[ `custom${ upperFirst( colorAttributeName ) }` ], + ); + + const previousColorObject = previousState[ colorAttributeName ]; + const previousColor = get( previousColorObject, [ 'color' ] ); + /** + * The "and previousColorObject" condition checks that a previous color object was already computed. + * At the start previousColorObject and colorValue are both equal to undefined + * bus as previousColorObject does not exist we should compute the object. + */ + if ( previousColor === colorObject.color && previousColorObject ) { + newState[ colorAttributeName ] = previousColorObject; + } else { + newState[ colorAttributeName ] = { + ...colorObject, + class: getColorClassName( colorContext, colorObject.slug ), + }; + } + return newState; + }, {} ); + } + + render() { + return ( + <WrappedComponent + { ...{ + ...this.props, + colors: undefined, + ...this.state, + ...this.setters, + colorUtils: this.colorUtils, + } } + /> + ); + } + }; + }, + ] ); +} + +/** + * A higher-order component factory for creating a 'withCustomColors' HOC, which handles color logic + * for class generation color value, retrieval and color attribute setting. + * + * Use this higher-order component to work with a custom set of colors. + * + * @example + * + * ```jsx + * const CUSTOM_COLORS = [ { name: 'Red', slug: 'red', color: '#ff0000' }, { name: 'Blue', slug: 'blue', color: '#0000ff' } ]; + * const withCustomColors = createCustomColorsHOC( CUSTOM_COLORS ); + * // ... + * export default compose( + * withCustomColors( 'backgroundColor', 'borderColor' ), + * MyColorfulComponent, + * ); + * ``` + * + * @param {Array} colorsArray The array of color objects (name, slug, color, etc... ). + * + * @return {Function} Higher-order component. + */ +export function createCustomColorsHOC( colorsArray ) { + return ( ...colorTypes ) => { + const withColorPalette = withCustomColorPalette( colorsArray ); + return createHigherOrderComponent( createColorHOC( colorTypes, withColorPalette ), 'withCustomColors' ); + }; +} + +/** + * A higher-order component, which handles color logic for class generation color value, retrieval and color attribute setting. + * + * For use with the default editor/theme color palette. + * + * @example + * + * ```jsx + * export default compose( + * withColors( 'backgroundColor', { textColor: 'color' } ), + * MyColorfulComponent, + * ); + * ``` + * + * @param {...(Object|string)} colorTypes The arguments can be strings or objects. If the argument is an object, + * it should contain the color attribute name as key and the color context as value. + * If the argument is a string the value should be the color attribute name, + * the color context is computed by applying a kebab case transform to the value. + * Color context represents the context/place where the color is going to be used. + * The class name of the color is generated using 'has' followed by the color name + * and ending with the color context all in kebab case e.g: has-green-background-color. + * + * @return {Function} Higher-order component. + */ +export default function withColors( ...colorTypes ) { + const withColorPalette = withEditorColorPalette(); + return createHigherOrderComponent( createColorHOC( colorTypes, withColorPalette ), 'withColors' ); +} diff --git a/gb-src/packages/block-editor/src/components/contrast-checker/index.js b/gb-src/packages/block-editor/src/components/contrast-checker/index.js new file mode 100644 index 0000000000000..308572e55531a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/contrast-checker/index.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import tinycolor from 'tinycolor2'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { __ } from '@wordpress/i18n'; +import { Notice } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; + +function ContrastCheckerMessage( { tinyBackgroundColor, tinyTextColor, backgroundColor, textColor } ) { + const msg = tinyBackgroundColor.getBrightness() < tinyTextColor.getBrightness() ? + __( 'This color combination may be hard for people to read. Try using a darker background color and/or a brighter text color.' ) : + __( 'This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color.' ); + useEffect( () => { + speak( __( 'This color combination may be hard for people to read.' ) ); + }, [ backgroundColor, textColor ] ); + return ( + <div className="editor-contrast-checker block-editor-contrast-checker"> + <Notice status="warning" isDismissible={ false }> + { msg } + </Notice> + </div> + ); +} + +function ContrastChecker( { + backgroundColor, + fallbackBackgroundColor, + fallbackTextColor, + fontSize, // font size value in pixels + isLargeText, + textColor, +} ) { + if ( ! ( backgroundColor || fallbackBackgroundColor ) || ! ( textColor || fallbackTextColor ) ) { + return null; + } + const tinyBackgroundColor = tinycolor( backgroundColor || fallbackBackgroundColor ); + const tinyTextColor = tinycolor( textColor || fallbackTextColor ); + const hasTransparency = tinyBackgroundColor.getAlpha() !== 1 || tinyTextColor.getAlpha() !== 1; + + if ( hasTransparency || tinycolor.isReadable( + tinyBackgroundColor, + tinyTextColor, + { level: 'AA', size: ( isLargeText || ( isLargeText !== false && fontSize >= 24 ) ? 'large' : 'small' ) } + ) ) { + return null; + } + + return ( + <ContrastCheckerMessage + backgroundColor={ backgroundColor } + textColor={ textColor } + tinyBackgroundColor={ tinyBackgroundColor } + tinyTextColor={ tinyTextColor } + /> + ); +} + +export default ContrastChecker; diff --git a/gb-src/packages/block-editor/src/components/contrast-checker/style.scss b/gb-src/packages/block-editor/src/components/contrast-checker/style.scss new file mode 100644 index 0000000000000..b3b08d6230d05 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/contrast-checker/style.scss @@ -0,0 +1,3 @@ +.block-editor-contrast-checker > .components-notice { + margin: 0; +} diff --git a/gb-src/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..49f7c916e7151 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap @@ -0,0 +1,364 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContrastChecker should render component when the colors do not meet AA WCAG guidelines. 1`] = ` +<ContrastChecker + backgroundColor="#666" + fallbackBackgroundColor="#fff" + fallbackTextColor="#000" + isLargeText={true} + textColor="#666" +> + <ContrastCheckerMessage + backgroundColor="#666" + textColor="#666" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 102, + "_format": "hex", + "_g": 102, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#666", + "_r": 102, + "_roundA": 1, + "_tc_id": 2, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 102, + "_format": "hex", + "_g": 102, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#666", + "_r": 102, + "_roundA": 1, + "_tc_id": 3, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; + +exports[`ContrastChecker should render different message matching snapshot when background color has less brightness than text color. 1`] = ` +<ContrastChecker + backgroundColor="#555" + fallbackBackgroundColor="#fff" + fallbackTextColor="#000" + isLargeText={false} + textColor="#666" +> + <ContrastCheckerMessage + backgroundColor="#555" + textColor="#666" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 85, + "_format": "hex", + "_g": 85, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#555", + "_r": 85, + "_roundA": 1, + "_tc_id": 8, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 102, + "_format": "hex", + "_g": 102, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#666", + "_r": 102, + "_roundA": 1, + "_tc_id": 9, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a darker background color and/or a brighter text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; + +exports[`ContrastChecker should render messages when the textColor is valid, but the fallback backgroundColor conflicts. 1`] = ` +<ContrastChecker + fallbackBackgroundColor="#000000" + textColor="#000000" +> + <ContrastCheckerMessage + textColor="#000000" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 0, + "_format": "hex", + "_g": 0, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#000000", + "_r": 0, + "_roundA": 1, + "_tc_id": 24, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 0, + "_format": "hex", + "_g": 0, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#000000", + "_r": 0, + "_roundA": 1, + "_tc_id": 25, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; + +exports[`ContrastChecker should take into consideration the font size passed 1`] = ` +<ContrastChecker + backgroundColor="#C44B4B" + fontSize={23} + textColor="#000000" +> + <ContrastCheckerMessage + backgroundColor="#C44B4B" + textColor="#000000" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 75, + "_format": "hex", + "_g": 75, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#C44B4B", + "_r": 196, + "_roundA": 1, + "_tc_id": 14, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 0, + "_format": "hex", + "_g": 0, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#000000", + "_r": 0, + "_roundA": 1, + "_tc_id": 15, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; + +exports[`ContrastChecker should take into consideration wherever text is large or not 1`] = ` +<ContrastChecker + backgroundColor="#C44B4B" + isLargeText={false} + textColor="#000000" +> + <ContrastCheckerMessage + backgroundColor="#C44B4B" + textColor="#000000" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 75, + "_format": "hex", + "_g": 75, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#C44B4B", + "_r": 196, + "_roundA": 1, + "_tc_id": 10, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 0, + "_format": "hex", + "_g": 0, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#000000", + "_r": 0, + "_roundA": 1, + "_tc_id": 11, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; + +exports[`ContrastChecker should use isLargeText to make decisions if both isLargeText and fontSize props are passed 1`] = ` +<ContrastChecker + backgroundColor="#C44B4B" + fontSize={24} + isLargeText={false} + textColor="#000000" +> + <ContrastCheckerMessage + backgroundColor="#C44B4B" + textColor="#000000" + tinyBackgroundColor={ + Object { + "_a": 1, + "_b": 75, + "_format": "hex", + "_g": 75, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#C44B4B", + "_r": 196, + "_roundA": 1, + "_tc_id": 20, + } + } + tinyTextColor={ + Object { + "_a": 1, + "_b": 0, + "_format": "hex", + "_g": 0, + "_gradientType": undefined, + "_ok": true, + "_originalInput": "#000000", + "_r": 0, + "_roundA": 1, + "_tc_id": 21, + } + } + > + <div + className="editor-contrast-checker block-editor-contrast-checker" + > + <Notice + isDismissible={false} + status="warning" + > + <div + className="components-notice is-warning" + > + <div + className="components-notice__content" + > + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. + </div> + </div> + </Notice> + </div> + </ContrastCheckerMessage> +</ContrastChecker> +`; diff --git a/gb-src/packages/block-editor/src/components/contrast-checker/test/index.js b/gb-src/packages/block-editor/src/components/contrast-checker/test/index.js new file mode 100644 index 0000000000000..eca63c0e7b97d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/contrast-checker/test/index.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import ContrastChecker from '../'; + +describe( 'ContrastChecker', () => { + const backgroundColor = '#ffffff'; + const textColor = '#000000'; + const isLargeText = true; + const fallbackBackgroundColor = '#fff'; + const fallbackTextColor = '#000'; + const sameShade = '#666'; + const colorWithTransparency = 'rgba(102,102,102,0.5)'; + + const wrapper = mount( + <ContrastChecker + backgroundColor={ backgroundColor } + textColor={ textColor } + isLargeText={ isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + test( 'should render null when no colors are provided', () => { + expect( mount( <ContrastChecker /> ).html() ).toBeNull(); + } ); + + test( 'should render null when the colors meet AA WCAG guidelines.', () => { + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should render component when the colors do not meet AA WCAG guidelines.', () => { + const componentWrapper = mount( + <ContrastChecker + backgroundColor={ sameShade } + textColor={ sameShade } + isLargeText={ isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + expect( componentWrapper ).toMatchSnapshot(); + } ); + + test( 'should render render null if background color contains a transparency', () => { + const componentWrapper = mount( + <ContrastChecker + backgroundColor={ colorWithTransparency } + textColor={ sameShade } + isLargeText={ isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + expect( componentWrapper.html() ).toBeNull(); + } ); + + test( 'should render render null if text color contains a transparency', () => { + const componentWrapper = mount( + <ContrastChecker + backgroundColor={ sameShade } + textColor={ colorWithTransparency } + isLargeText={ isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + expect( componentWrapper.html() ).toBeNull(); + } ); + + test( 'should render different message matching snapshot when background color has less brightness than text color.', () => { + const darkerShade = '#555'; + + const componentWrapper = mount( + <ContrastChecker + backgroundColor={ darkerShade } + textColor={ sameShade } + isLargeText={ ! isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + expect( componentWrapper ).toMatchSnapshot(); + } ); + + test( 'should take into consideration wherever text is large or not', () => { + const componentWrapperSmallText = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + isLargeText={ false } + /> + ); + + expect( componentWrapperSmallText ).toMatchSnapshot(); + + const componentWrapperLargeText = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + isLargeText={ true } + /> + ); + + expect( componentWrapperLargeText.html() ).toBeNull(); + } ); + + test( 'should take into consideration the font size passed', () => { + const componentWrapperSmallFontSize = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + fontSize={ 23 } + /> + ); + + expect( componentWrapperSmallFontSize ).toMatchSnapshot(); + + const componentWrapperLargeText = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + fontSize={ 24 } + /> + ); + + expect( componentWrapperLargeText.html() ).toBeNull(); + } ); + + test( 'should use isLargeText to make decisions if both isLargeText and fontSize props are passed', () => { + const componentWrapper = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + fontSize={ 23 } + isLargeText={ true } + /> + ); + + expect( componentWrapper.html() ).toBeNull(); + + const componentWrapperNoLargeText = mount( + <ContrastChecker + backgroundColor="#C44B4B" + textColor="#000000" + fontSize={ 24 } + isLargeText={ false } + /> + ); + + expect( componentWrapperNoLargeText ).toMatchSnapshot(); + } ); + + test( 'should render null when the colors meet AA WCAG guidelines, with only fallback colors.', () => { + const componentWrapper = mount( + <ContrastChecker + isLargeText={ isLargeText } + fallbackBackgroundColor={ fallbackBackgroundColor } + fallbackTextColor={ fallbackTextColor } /> + ); + + expect( componentWrapper.html() ).toBeNull(); + } ); + + test( 'should render messages when the textColor is valid, but the fallback backgroundColor conflicts.', () => { + const componentWrapper = mount( + <ContrastChecker + textColor={ textColor } + fallbackBackgroundColor={ textColor } /> + ); + + expect( componentWrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/copy-handler/index.js b/gb-src/packages/block-editor/src/components/copy-handler/index.js new file mode 100644 index 0000000000000..75b2db4098858 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/copy-handler/index.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { serialize } from '@wordpress/blocks'; +import { documentHasSelection } from '@wordpress/dom'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +function CopyHandler( { children, onCopy, onCut } ) { + return ( + <div onCopy={ onCopy } onCut={ onCut }> + { children } + </div> + ); +} + +export default compose( [ + withDispatch( ( dispatch, ownProps, { select } ) => { + const { + getBlocksByClientId, + getSelectedBlockClientIds, + hasMultiSelection, + } = select( 'core/block-editor' ); + const { removeBlocks } = dispatch( 'core/block-editor' ); + + const onCopy = ( event ) => { + const selectedBlockClientIds = getSelectedBlockClientIds(); + + if ( selectedBlockClientIds.length === 0 ) { + return; + } + + // Let native copy behaviour take over in input fields. + if ( ! hasMultiSelection() && documentHasSelection() ) { + return; + } + + const serialized = serialize( getBlocksByClientId( selectedBlockClientIds ) ); + + event.clipboardData.setData( 'text/plain', serialized ); + event.clipboardData.setData( 'text/html', serialized ); + + event.preventDefault(); + }; + + return { + onCopy, + onCut( event ) { + onCopy( event ); + + if ( hasMultiSelection() ) { + const selectedBlockClientIds = getSelectedBlockClientIds(); + + removeBlocks( selectedBlockClientIds ); + } + }, + }; + } ), +] )( CopyHandler ); diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/index.js b/gb-src/packages/block-editor/src/components/default-block-appender/index.js new file mode 100644 index 0000000000000..3baf90e440609 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/index.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import TextareaAutosize from 'react-autosize-textarea'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose, withState } from '@wordpress/compose'; +import { getDefaultBlockName } from '@wordpress/blocks'; +import { decodeEntities } from '@wordpress/html-entities'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockDropZone from '../block-drop-zone'; +import InserterWithShortcuts from '../inserter-with-shortcuts'; +import Inserter from '../inserter'; + +export function DefaultBlockAppender( { + isLocked, + isVisible, + onAppend, + showPrompt, + placeholder, + rootClientId, + hovered, + setState, +} ) { + if ( isLocked || ! isVisible ) { + return null; + } + + const value = decodeEntities( placeholder ) || __( 'Start writing or type / to choose a block' ); + + // The appender "button" is in-fact a text field so as to support + // transitions by WritingFlow occurring by arrow key press. WritingFlow + // only supports tab transitions into text fields and to the block focus + // boundary. + // + // See: https://github.com/WordPress/gutenberg/issues/4829#issuecomment-374213658 + // + // If it were ever to be made to be a proper `button` element, it is + // important to note that `onFocus` alone would not be sufficient to + // capture click events, notably in Firefox. + // + // See: https://gist.github.com/cvrebert/68659d0333a578d75372 + + // The wp-block className is important for editor styles. + + return ( + <div + data-root-client-id={ rootClientId || '' } + className="wp-block editor-default-block-appender block-editor-default-block-appender" + onMouseEnter={ () => setState( { hovered: true } ) } + onMouseLeave={ () => setState( { hovered: false } ) } + > + <BlockDropZone rootClientId={ rootClientId } /> + <TextareaAutosize + role="button" + aria-label={ __( 'Add block' ) } + className="editor-default-block-appender__content block-editor-default-block-appender__content" + readOnly + onFocus={ onAppend } + value={ showPrompt ? value : '' } + /> + { hovered && <InserterWithShortcuts rootClientId={ rootClientId } /> } + <Inserter rootClientId={ rootClientId } position="top right" isAppender /> + </div> + ); +} +export default compose( + withState( { hovered: false } ), + withSelect( ( select, ownProps ) => { + const { getBlockCount, getBlockName, isBlockValid, getSettings, getTemplateLock } = select( 'core/block-editor' ); + + const isEmpty = ! getBlockCount( ownProps.rootClientId ); + const isLastBlockDefault = getBlockName( ownProps.lastBlockClientId ) === getDefaultBlockName(); + const isLastBlockValid = isBlockValid( ownProps.lastBlockClientId ); + const { bodyPlaceholder } = getSettings(); + + return { + isVisible: isEmpty || ! isLastBlockDefault || ! isLastBlockValid, + showPrompt: isEmpty, + isLocked: !! getTemplateLock( ownProps.rootClientId ), + placeholder: bodyPlaceholder, + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { + insertDefaultBlock, + startTyping, + } = dispatch( 'core/block-editor' ); + + return { + onAppend() { + const { rootClientId } = ownProps; + + insertDefaultBlock( undefined, rootClientId ); + startTyping(); + }, + }; + } ), +)( DefaultBlockAppender ); diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/index.native.js b/gb-src/packages/block-editor/src/components/default-block-appender/index.native.js new file mode 100644 index 0000000000000..4f361376f6d58 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/index.native.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { TouchableWithoutFeedback, View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { RichText } from '@wordpress/block-editor'; +import { compose } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { getDefaultBlockName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export function DefaultBlockAppender( { + isLocked, + isVisible, + onAppend, + placeholder, + containerStyle, +} ) { + if ( isLocked || ! isVisible ) { + return null; + } + + const value = typeof placeholder === 'string' ? decodeEntities( placeholder ) : __( 'Start writing…' ); + + return ( + <TouchableWithoutFeedback + onPress={ onAppend } + > + <View style={ [ styles.blockHolder, containerStyle ] } pointerEvents="box-only"> + <RichText + placeholder={ value } + onChange={ () => {} } + /> + </View> + </TouchableWithoutFeedback> + ); +} + +export default compose( + withSelect( ( select, ownProps ) => { + const { getBlockCount, getBlockName, isBlockValid, getTemplateLock } = select( 'core/block-editor' ); + + const isEmpty = ! getBlockCount( ownProps.rootClientId ); + const isLastBlockDefault = getBlockName( ownProps.lastBlockClientId ) === getDefaultBlockName(); + const isLastBlockValid = isBlockValid( ownProps.lastBlockClientId ); + + return { + isVisible: isEmpty || ! isLastBlockDefault || ! isLastBlockValid, + isLocked: !! getTemplateLock( ownProps.rootClientId ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { + insertDefaultBlock, + startTyping, + } = dispatch( 'core/block-editor' ); + + return { + onAppend() { + const { rootClientId } = ownProps; + + insertDefaultBlock( undefined, rootClientId ); + startTyping(); + }, + }; + } ), +)( DefaultBlockAppender ); diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/style.native.scss b/gb-src/packages/block-editor/src/components/default-block-appender/style.native.scss new file mode 100644 index 0000000000000..5193611fa45d5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/style.native.scss @@ -0,0 +1,18 @@ +// @format + +.blockHolder { + flex: 1 1 auto; +} + +.blockContainer { + background-color: $white; + padding-top: 0; + padding-left: 16px; + padding-right: 16px; +} + +.textView { + color: $gray; + font-size: 16px; + font-family: $default-regular-font; +} diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/style.scss b/gb-src/packages/block-editor/src/components/default-block-appender/style.scss new file mode 100644 index 0000000000000..b3228aaab4170 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/style.scss @@ -0,0 +1,143 @@ +.block-editor-default-block-appender { + clear: both; // The appender doesn't scale well to sit next to floats, so clear them. + + // Default to centered and content-width, like blocks + margin-left: auto; + margin-right: auto; + position: relative; + + &[data-root-client-id=""] .block-editor-default-block-appender__content:hover { + // Outline on root-level default block appender is redundant with the + // WritingFlow click redirector. + outline: 1px solid transparent; + } + + textarea.block-editor-default-block-appender__content { // Needs specificity in order to override input field styles from WP-admin styles. + font-family: $editor-font; + font-size: $editor-font-size; // It should match the default paragraph size. + border: none; + background: none; + box-shadow: none; + display: block; + cursor: text; + width: 100%; + outline: $border-width solid transparent; + transition: 0.2s outline; + @include reduce-motion("transition"); + resize: none; + margin-top: $default-block-margin; + margin-bottom: $default-block-margin; + + // Emulate the dimensions of a paragraph block. + // On mobile and in nested contexts, the plus to add blocks shows up on the right. + // The rightmost padding makes sure it doesn't overlap text. + padding: 0 #{ $block-padding + $icon-button-size } 0 $block-padding; + + // Use opacity to work in various editor styles. + color: $dark-opacity-300; + .is-dark-theme & { + color: $light-opacity-300; + } + } + + &:hover { + .block-editor-inserter-with-shortcuts { + @include edit-post__fade-in-animation; + } + } + + // Dropzone. + .components-drop-zone__content-icon { + display: none; + } +} + +// Ensure that the height of the first appender, and the one between blocks, is the same as text. +.block-editor-default-block-appender__content { + min-height: $empty-paragraph-height / 2; + line-height: $editor-line-height; +} + +// Quick shortcuts, left and right. +.block-editor-block-list__empty-block-inserter, // Empty paragraph +.block-editor-default-block-appender .block-editor-inserter, // Empty appender +.block-editor-inserter-with-shortcuts { // Right side quick shortcuts + position: absolute; + top: 0; + + // Change the size of the buttons to match that of the default paragraph height. + .components-icon-button { + width: $block-side-ui-width; + height: $block-side-ui-width; + margin-right: 12px; + padding: 0; + } + + .block-editor-block-icon { + margin: auto; + } + + .components-icon-button svg { + display: block; + margin: auto; + } + + .block-editor-inserter__toggle { + margin-right: 0; + + // Hide the box shadow that appears on hover. + // All the :not() rules are needed to override default iconButton styles. + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + } + } +} + +// Left side. +.block-editor-block-list__empty-block-inserter, +.block-editor-default-block-appender .block-editor-inserter { + right: $grid-size; // Show to the right on mobile. + + @include break-small { + display: flex; + align-items: center; + height: 100%; + left: -$block-side-ui-width - $block-padding - $block-side-ui-clearance; + right: auto; + } + + &:disabled { + display: none; + } + + .block-editor-inserter__toggle { + border-radius: 50%; + width: $block-side-ui-width; + height: $block-side-ui-width; + padding: 0; + + // Use opacity to work in various editor styles. + &:not(:hover) { + color: $dark-opacity-500; + .is-dark-theme & { + color: $light-opacity-500; + } + } + } +} + +// Quick block insertion icons on the right side. +// Needs specificity to styles from the component itself. +.block-editor-block-list__side-inserter .block-editor-inserter-with-shortcuts, +.block-editor-default-block-appender .block-editor-inserter-with-shortcuts { + right: $block-padding; + display: none; // Don't show on mobile. + z-index: z-index(".block-editor-inserter-with-shortcuts"); // Elevate above the sibling inserter. + + @include break-small { + display: flex; + align-items: center; + height: 100%; + right: 0; + } +} diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/default-block-appender/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..9a09dae0d4767 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/test/__snapshots__/index.js.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultBlockAppender should append a default block when input focused 1`] = ` +<div + className="wp-block editor-default-block-appender block-editor-default-block-appender" + data-root-client-id="" + onMouseEnter={[Function]} + onMouseLeave={[Function]} +> + <WithDispatch(WithSelect(WithFilters(BlockDropZone))) /> + <TextareaAutosize + aria-label="Add block" + className="editor-default-block-appender__content block-editor-default-block-appender__content" + onFocus={ + [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + } + } + readOnly={true} + role="button" + rows={1} + value="Start writing or type / to choose a block" + /> + <WithSelect(IfCondition(Inserter)) + isAppender={true} + position="top right" + /> +</div> +`; + +exports[`DefaultBlockAppender should match snapshot 1`] = ` +<div + className="wp-block editor-default-block-appender block-editor-default-block-appender" + data-root-client-id="" + onMouseEnter={[Function]} + onMouseLeave={[Function]} +> + <WithDispatch(WithSelect(WithFilters(BlockDropZone))) /> + <TextareaAutosize + aria-label="Add block" + className="editor-default-block-appender__content block-editor-default-block-appender__content" + onFocus={[MockFunction]} + readOnly={true} + role="button" + rows={1} + value="Start writing or type / to choose a block" + /> + <WithSelect(IfCondition(Inserter)) + isAppender={true} + position="top right" + /> +</div> +`; + +exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` +<div + className="wp-block editor-default-block-appender block-editor-default-block-appender" + data-root-client-id="" + onMouseEnter={[Function]} + onMouseLeave={[Function]} +> + <WithDispatch(WithSelect(WithFilters(BlockDropZone))) /> + <TextareaAutosize + aria-label="Add block" + className="editor-default-block-appender__content block-editor-default-block-appender__content" + onFocus={[MockFunction]} + readOnly={true} + role="button" + rows={1} + value="" + /> + <WithSelect(IfCondition(Inserter)) + isAppender={true} + position="top right" + /> +</div> +`; diff --git a/gb-src/packages/block-editor/src/components/default-block-appender/test/index.js b/gb-src/packages/block-editor/src/components/default-block-appender/test/index.js new file mode 100644 index 0000000000000..80517843ee661 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-block-appender/test/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { DefaultBlockAppender } from '../'; + +describe( 'DefaultBlockAppender', () => { + const expectOnAppendCalled = ( onAppend ) => { + expect( onAppend ).toHaveBeenCalledTimes( 1 ); + expect( onAppend ).toHaveBeenCalledWith(); + }; + + it( 'should render nothing if not visible', () => { + const wrapper = shallow( <DefaultBlockAppender /> ); + + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'should match snapshot', () => { + const onAppend = jest.fn(); + const wrapper = shallow( <DefaultBlockAppender isVisible onAppend={ onAppend } showPrompt /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should append a default block when input focused', () => { + const onAppend = jest.fn(); + const wrapper = shallow( <DefaultBlockAppender isVisible onAppend={ onAppend } showPrompt /> ); + + wrapper.find( 'TextareaAutosize' ).simulate( 'focus' ); + + expect( wrapper ).toMatchSnapshot(); + + expectOnAppendCalled( onAppend ); + } ); + + it( 'should optionally show without prompt', () => { + const onAppend = jest.fn(); + const wrapper = shallow( <DefaultBlockAppender isVisible onAppend={ onAppend } showPrompt={ false } /> ); + const input = wrapper.find( 'TextareaAutosize' ); + + expect( input.prop( 'value' ) ).toEqual( '' ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/default-style-picker/index.js b/gb-src/packages/block-editor/src/components/default-style-picker/index.js new file mode 100644 index 0000000000000..7be972419f67b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/default-style-picker/index.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useMemo, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { SelectControl } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; + +export default function DefaultStylePicker( { blockName } ) { + const { + preferredStyle, + onUpdatePreferredStyleVariations, + styles, + } = useSelect( + ( select ) => { + const settings = select( 'core/block-editor' ).getSettings(); + const preferredStyleVariations = settings.__experimentalPreferredStyleVariations; + return { + preferredStyle: get( + preferredStyleVariations, + [ 'value', blockName ] + ), + onUpdatePreferredStyleVariations: get( + preferredStyleVariations, + [ 'onChange' ], + null + ), + styles: select( 'core/blocks' ).getBlockStyles( blockName ), + }; + }, + [ blockName ] + ); + const selectOptions = useMemo( + () => ( [ + { label: __( 'Not set' ), value: '' }, + ...styles.map( ( { label, name } ) => ( { label, value: name } ) ), + ] ), + [ styles ], + ); + const selectOnChange = useCallback( + ( blockStyle ) => { + onUpdatePreferredStyleVariations( blockName, blockStyle ); + }, + [ blockName, onUpdatePreferredStyleVariations ] + ); + + return onUpdatePreferredStyleVariations && ( + <SelectControl + options={ selectOptions } + value={ preferredStyle || '' } + label={ __( 'Default Style' ) } + onChange={ selectOnChange } + /> + ); +} diff --git a/gb-src/packages/block-editor/src/components/font-sizes/font-size-picker.js b/gb-src/packages/block-editor/src/components/font-sizes/font-size-picker.js new file mode 100644 index 0000000000000..e2dbe3dba8134 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/font-sizes/font-size-picker.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { FontSizePicker } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; + +export default withSelect( + ( select ) => { + const { + disableCustomFontSizes, + fontSizes, + } = select( 'core/block-editor' ).getSettings(); + + return { + disableCustomFontSizes, + fontSizes, + }; + } +)( FontSizePicker ); diff --git a/gb-src/packages/block-editor/src/components/font-sizes/index.js b/gb-src/packages/block-editor/src/components/font-sizes/index.js new file mode 100644 index 0000000000000..931f9c2a89474 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/font-sizes/index.js @@ -0,0 +1,3 @@ +export { getFontSize, getFontSizeClass } from './utils'; +export { default as FontSizePicker } from './font-size-picker'; +export { default as withFontSizes } from './with-font-sizes'; diff --git a/gb-src/packages/block-editor/src/components/font-sizes/index.native.js b/gb-src/packages/block-editor/src/components/font-sizes/index.native.js new file mode 100644 index 0000000000000..2ebb67cf494ba --- /dev/null +++ b/gb-src/packages/block-editor/src/components/font-sizes/index.native.js @@ -0,0 +1 @@ +export { getFontSize, getFontSizeClass } from './utils'; diff --git a/gb-src/packages/block-editor/src/components/font-sizes/utils.js b/gb-src/packages/block-editor/src/components/font-sizes/utils.js new file mode 100644 index 0000000000000..09de76dcf7f6a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/font-sizes/utils.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { find, kebabCase } from 'lodash'; + +/** + * Returns the font size object based on an array of named font sizes and the namedFontSize and customFontSize values. + * If namedFontSize is undefined or not found in fontSizes an object with just the size value based on customFontSize is returned. + * + * @param {Array} fontSizes Array of font size objects containing at least the "name" and "size" values as properties. + * @param {?string} fontSizeAttribute Content of the font size attribute (slug). + * @param {?number} customFontSizeAttribute Contents of the custom font size attribute (value). + * + * @return {?string} If fontSizeAttribute is set and an equal slug is found in fontSizes it returns the font size object for that slug. + * Otherwise, an object with just the size value based on customFontSize is returned. + */ +export const getFontSize = ( fontSizes, fontSizeAttribute, customFontSizeAttribute ) => { + if ( fontSizeAttribute ) { + const fontSizeObject = find( fontSizes, { slug: fontSizeAttribute } ); + if ( fontSizeObject ) { + return fontSizeObject; + } + } + return { + size: customFontSizeAttribute, + }; +}; + +/** + * Returns a class based on fontSizeName. + * + * @param {string} fontSizeSlug Slug of the fontSize. + * + * @return {string} String with the class corresponding to the fontSize passed. + * The class is generated by appending 'has-' followed by fontSizeSlug in kebabCase and ending with '-font-size'. + */ +export function getFontSizeClass( fontSizeSlug ) { + if ( ! fontSizeSlug ) { + return; + } + + return `has-${ kebabCase( fontSizeSlug ) }-font-size`; +} diff --git a/gb-src/packages/block-editor/src/components/font-sizes/with-font-sizes.js b/gb-src/packages/block-editor/src/components/font-sizes/with-font-sizes.js new file mode 100644 index 0000000000000..f64fe8d4f7600 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/font-sizes/with-font-sizes.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { find, pickBy, reduce, some, upperFirst } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent, compose } from '@wordpress/compose'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { getFontSize, getFontSizeClass } from './utils'; + +/** + * Higher-order component, which handles font size logic for class generation, + * font size value retrieval, and font size change handling. + * + * @param {...(Object|string)} fontSizeNames The arguments should all be strings. + * Each string contains the font size + * attribute name e.g: 'fontSize'. + * + * @return {Function} Higher-order component. + */ +export default ( ...fontSizeNames ) => { + /* + * Computes an object whose key is the font size attribute name as passed in the array, + * and the value is the custom font size attribute name. + * Custom font size is automatically compted by appending custom followed by the font size attribute name in with the first letter capitalized. + */ + const fontSizeAttributeNames = reduce( fontSizeNames, ( fontSizeAttributeNamesAccumulator, fontSizeAttributeName ) => { + fontSizeAttributeNamesAccumulator[ fontSizeAttributeName ] = `custom${ upperFirst( fontSizeAttributeName ) }`; + return fontSizeAttributeNamesAccumulator; + }, {} ); + + return createHigherOrderComponent( + compose( [ + withSelect( ( select ) => { + const { fontSizes } = select( 'core/block-editor' ).getSettings(); + return { + fontSizes, + }; + } ), + ( WrappedComponent ) => { + return class extends Component { + constructor( props ) { + super( props ); + + this.setters = this.createSetters(); + + this.state = {}; + } + + createSetters() { + return reduce( fontSizeAttributeNames, ( settersAccumulator, customFontSizeAttributeName, fontSizeAttributeName ) => { + const upperFirstFontSizeAttributeName = upperFirst( fontSizeAttributeName ); + settersAccumulator[ `set${ upperFirstFontSizeAttributeName }` ] = + this.createSetFontSize( fontSizeAttributeName, customFontSizeAttributeName ); + return settersAccumulator; + }, {} ); + } + + createSetFontSize( fontSizeAttributeName, customFontSizeAttributeName ) { + return ( fontSizeValue ) => { + const fontSizeObject = find( this.props.fontSizes, { size: Number( fontSizeValue ) } ); + this.props.setAttributes( { + [ fontSizeAttributeName ]: fontSizeObject && fontSizeObject.slug ? fontSizeObject.slug : undefined, + [ customFontSizeAttributeName ]: fontSizeObject && fontSizeObject.slug ? undefined : fontSizeValue, + } ); + }; + } + + static getDerivedStateFromProps( { attributes, fontSizes }, previousState ) { + const didAttributesChange = ( customFontSizeAttributeName, fontSizeAttributeName ) => { + if ( previousState[ fontSizeAttributeName ] ) { + // if new font size is name compare with the previous slug + if ( attributes[ fontSizeAttributeName ] ) { + return attributes[ fontSizeAttributeName ] !== previousState[ fontSizeAttributeName ].slug; + } + // if font size is not named, update when the font size value changes. + return previousState[ fontSizeAttributeName ].size !== attributes[ customFontSizeAttributeName ]; + } + // in this case we need to build the font size object + return true; + }; + + if ( ! some( fontSizeAttributeNames, didAttributesChange ) ) { + return null; + } + + const newState = reduce( + pickBy( fontSizeAttributeNames, didAttributesChange ), + ( newStateAccumulator, customFontSizeAttributeName, fontSizeAttributeName ) => { + const fontSizeAttributeValue = attributes[ fontSizeAttributeName ]; + const fontSizeObject = getFontSize( + fontSizes, + fontSizeAttributeValue, + attributes[ customFontSizeAttributeName ] + ); + newStateAccumulator[ fontSizeAttributeName ] = { + ...fontSizeObject, + class: getFontSizeClass( fontSizeAttributeValue ), + }; + return newStateAccumulator; + }, + {} + ); + + return { + ...previousState, + ...newState, + }; + } + + render() { + return ( + <WrappedComponent + { ...{ + ...this.props, + fontSizes: undefined, + ...this.state, + ...this.setters, + } } + /> + ); + } + }; + }, + ] ), + 'withFontSizes' + ); +}; diff --git a/gb-src/packages/block-editor/src/components/ignore-nested-events/index.js b/gb-src/packages/block-editor/src/components/ignore-nested-events/index.js new file mode 100644 index 0000000000000..2592ba3abd052 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/ignore-nested-events/index.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import { reduce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, forwardRef, createElement } from '@wordpress/element'; + +/** + * Component which renders a div with passed props applied except the optional + * `childHandledEvents` prop. Event prop handlers are replaced with a proxying + * event handler to capture and prevent events from being handled by ancestor + * `IgnoreNestedEvents` elements by testing the presence of a private property + * assigned on the event object. + * + * Optionally accepts an `childHandledEvents` prop array, which can be used in + * instances where an inner `IgnoreNestedEvents` element exists and the outer + * element should stop propagation but not invoke a callback handler, since it + * would be assumed these are invoked by the child element. + * + * @type {Component} + */ +export class IgnoreNestedEvents extends Component { + constructor() { + super( ...arguments ); + + this.proxyEvent = this.proxyEvent.bind( this ); + + // The event map is responsible for tracking an event type to a React + // component prop name, since it is easy to determine event type from + // a React prop name, but not the other way around. + this.eventMap = {}; + } + + /** + * General event handler which only calls to its original props callback if + * it has not already been handled by a descendant IgnoreNestedEvents. + * + * @param {Event} event Event object. + */ + proxyEvent( event ) { + const isHandled = !! event.nativeEvent._blockHandled; + + // Assign into the native event, since React will reuse their synthetic + // event objects and this property assignment could otherwise leak. + // + // See: https://reactjs.org/docs/events.html#event-pooling + event.nativeEvent._blockHandled = true; + + // Invoke original prop handler + let propKey = this.eventMap[ event.type ]; + + // If already handled (i.e. assume nested block), only invoke a + // corresponding "Handled"-suffixed prop callback. + if ( isHandled ) { + propKey += 'Handled'; + } + + if ( this.props[ propKey ] ) { + this.props[ propKey ]( event ); + } + } + + render() { + const { childHandledEvents = [], forwardedRef, tagName = 'div', ...props } = this.props; + + const eventHandlers = reduce( [ + ...childHandledEvents, + ...Object.keys( props ), + ], ( result, key ) => { + // Try to match prop key as event handler + const match = key.match( /^on([A-Z][a-zA-Z]+?)(Handled)?$/ ); + if ( match ) { + const isHandledProp = !! match[ 2 ]; + if ( isHandledProp ) { + // Avoid assigning through the invalid prop key. This + // assumes mutation of shallow clone by above spread. + delete props[ key ]; + } + + // Re-map the prop to the local proxy handler to check whether + // the event has already been handled. + const proxiedPropName = 'on' + match[ 1 ]; + result[ proxiedPropName ] = this.proxyEvent; + + // Assign event -> propName into an instance variable, so as to + // avoid re-renders which could be incurred either by setState + // or in mapping values to a newly created function. + this.eventMap[ match[ 1 ].toLowerCase() ] = proxiedPropName; + } + + return result; + }, {} ); + + return createElement( tagName, { ref: forwardedRef, ...props, ...eventHandlers } ); + } +} + +const forwardedIgnoreNestedEvents = ( props, ref ) => { + return <IgnoreNestedEvents { ...props } forwardedRef={ ref } />; +}; +forwardedIgnoreNestedEvents.displayName = 'IgnoreNestedEvents'; + +export default forwardRef( forwardedIgnoreNestedEvents ); diff --git a/gb-src/packages/block-editor/src/components/ignore-nested-events/test/index.js b/gb-src/packages/block-editor/src/components/ignore-nested-events/test/index.js new file mode 100644 index 0000000000000..56ded3e4df340 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/ignore-nested-events/test/index.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { IgnoreNestedEvents } from '../'; + +describe( 'IgnoreNestedEvents', () => { + it( 'passes props to its rendered div', () => { + const wrapper = mount( + <IgnoreNestedEvents className="foo" /> + ); + + expect( wrapper.find( 'div' ) ).toHaveLength( 1 ); + expect( wrapper.prop( 'className' ) ).toBe( 'foo' ); + } ); + + it( 'stops propagation of events to ancestor IgnoreNestedEvents', () => { + const spyOuter = jest.fn(); + const spyInner = jest.fn(); + const wrapper = mount( + <IgnoreNestedEvents onClick={ spyOuter }> + <IgnoreNestedEvents onClick={ spyInner } /> + </IgnoreNestedEvents> + ); + + wrapper.find( 'div' ).last().simulate( 'click' ); + + expect( spyInner ).toHaveBeenCalled(); + expect( spyOuter ).not.toHaveBeenCalled(); + } ); + + it( 'stops propagation of child handled events', () => { + const spyOuter = jest.fn(); + const spyInner = jest.fn(); + const wrapper = mount( + <IgnoreNestedEvents onClick={ spyOuter }> + <IgnoreNestedEvents childHandledEvents={ [ 'onClick' ] }> + <div /> + <IgnoreNestedEvents onClick={ spyInner } /> + </IgnoreNestedEvents> + </IgnoreNestedEvents> + ); + + const div = wrapper.childAt( 0 ).childAt( 0 ); + div.simulate( 'click' ); + + expect( spyInner ).not.toHaveBeenCalled(); + expect( spyOuter ).not.toHaveBeenCalled(); + } ); + + it( 'invokes callback of Handled-suffixed prop if handled', () => { + const spyOuter = jest.fn(); + const spyInner = jest.fn(); + const wrapper = mount( + <IgnoreNestedEvents onClickHandled={ spyOuter }> + <IgnoreNestedEvents childHandledEvents={ [ 'onClick' ] }> + <div /> + <IgnoreNestedEvents onClick={ spyInner } /> + </IgnoreNestedEvents> + </IgnoreNestedEvents> + ); + + const div = wrapper.childAt( 0 ).childAt( 0 ); + div.simulate( 'click' ); + + expect( spyInner ).not.toHaveBeenCalled(); + expect( spyOuter ).toHaveBeenCalled(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/index.js b/gb-src/packages/block-editor/src/components/index.js new file mode 100644 index 0000000000000..84184ac70bd5b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/index.js @@ -0,0 +1,72 @@ +/* + * Block Creation Components + */ + +export * from './colors'; +export * from './font-sizes'; +export { default as AlignmentToolbar } from './alignment-toolbar'; +export { default as Autocomplete } from './autocomplete'; +export { default as BlockAlignmentToolbar } from './block-alignment-toolbar'; +export { default as BlockControls } from './block-controls'; +export { default as BlockEdit } from './block-edit'; +export { default as BlockFormatControls } from './block-format-controls'; +export { default as BlockIcon } from './block-icon'; +export { default as BlockNavigationDropdown } from './block-navigation/dropdown'; +export { default as __experimentalBlockNavigationList } from './block-navigation/list'; +export { default as BlockVerticalAlignmentToolbar } from './block-vertical-alignment-toolbar'; +export { default as ButtonBlockerAppender } from './button-block-appender'; +export { default as ColorPalette } from './color-palette'; +export { default as ContrastChecker } from './contrast-checker'; +export { default as InnerBlocks } from './inner-blocks'; +export { default as InspectorAdvancedControls } from './inspector-advanced-controls'; +export { default as InspectorControls } from './inspector-controls'; +export { default as MediaPlaceholder } from './media-placeholder'; +export { default as MediaUpload } from './media-upload'; +export { default as MediaUploadCheck } from './media-upload/check'; +export { default as PanelColorSettings } from './panel-color-settings'; +export { default as PlainText } from './plain-text'; +export { + default as RichText, + RichTextShortcut, + RichTextToolbarButton, + __unstableRichTextInputEvent, +} from './rich-text'; +export { default as URLInput } from './url-input'; +export { default as URLInputButton } from './url-input/button'; +export { default as URLPopover } from './url-popover'; +export { default as withColorContext } from './color-palette/with-color-context'; + +/* + * Content Related Components + */ + +export { default as __experimentalBlockSettingsMenuFirstItem } from './block-settings-menu/block-settings-menu-first-item'; +export { default as __experimentalBlockSettingsMenuPluginsExtension } from './block-settings-menu/block-settings-menu-plugins-extension'; +export { default as __experimentalInserterMenuExtension } from './inserter-menu-extension'; +export { default as BlockEditorKeyboardShortcuts } from './block-editor-keyboard-shortcuts'; +export { default as BlockInspector } from './block-inspector'; +export { default as BlockList } from './block-list'; +export { default as BlockMover } from './block-mover'; +export { default as BlockPreview } from './block-preview'; +export { default as BlockSelectionClearer } from './block-selection-clearer'; +export { default as BlockSettingsMenu } from './block-settings-menu'; +export { default as BlockTitle } from './block-title'; +export { default as BlockToolbar } from './block-toolbar'; +export { default as CopyHandler } from './copy-handler'; +export { default as DefaultBlockAppender } from './default-block-appender'; +export { default as Inserter } from './inserter'; +export { default as MultiBlocksSwitcher } from './block-switcher/multi-blocks-switcher'; +export { default as MultiSelectScrollIntoView } from './multi-select-scroll-into-view'; +export { default as NavigableToolbar } from './navigable-toolbar'; +export { default as ObserveTyping } from './observe-typing'; +export { default as PreserveScrollInReorder } from './preserve-scroll-in-reorder'; +export { default as SkipToSelectedBlock } from './skip-to-selected-block'; +export { default as Typewriter } from './typewriter'; +export { default as Warning } from './warning'; +export { default as WritingFlow } from './writing-flow'; + +/* + * State Related Components + */ + +export { default as BlockEditorProvider } from './provider'; diff --git a/gb-src/packages/block-editor/src/components/index.native.js b/gb-src/packages/block-editor/src/components/index.native.js new file mode 100644 index 0000000000000..5f13de7d91b4c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/index.native.js @@ -0,0 +1,30 @@ +// Block Creation Components +export { default as BlockControls } from './block-controls'; +export { default as BlockEdit } from './block-edit'; +export { default as BlockFormatControls } from './block-format-controls'; +export * from './colors'; +export * from './font-sizes'; +export { default as AlignmentToolbar } from './alignment-toolbar'; +export { default as InspectorControls } from './inspector-controls'; +export { default as PlainText } from './plain-text'; +export { + default as RichText, + RichTextShortcut, + RichTextToolbarButton, + __unstableRichTextInputEvent, +} from './rich-text'; +export { default as MediaPlaceholder } from './media-placeholder'; +export { default as MediaUpload, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO } from './media-upload'; +export { default as URLInput } from './url-input'; +export { default as BlockInvalidWarning } from './block-list/block-invalid-warning'; +export { default as Caption } from './caption'; + +// Content Related Components +export { default as BlockList } from './block-list'; +export { default as BlockMover } from './block-mover'; +export { default as BlockToolbar } from './block-toolbar'; +export { default as DefaultBlockAppender } from './default-block-appender'; +export { default as Inserter } from './inserter'; + +// State Related Components +export { default as BlockEditorProvider } from './provider'; diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/README.md b/gb-src/packages/block-editor/src/components/inner-blocks/README.md new file mode 100644 index 0000000000000..5bed06f7a8047 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/README.md @@ -0,0 +1,211 @@ +InnerBlocks +=========== + +InnerBlocks exports a pair of components which can be used in block implementations to enable nested block content. + +Refer to the [implementation of the Columns block](https://github.com/WordPress/gutenberg/tree/master/packages/block-library/src/columns) as an example resource. + +## Usage + +In a block's `edit` implementation, render `InnerBlocks`. Then, in the `save` implementation, render `InnerBlocks.Content`. This will be replaced automatically with the content of the nested blocks. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +registerBlockType( 'my-plugin/my-block', { + // ... + + edit( { className } ) { + return ( + <div className={ className }> + <InnerBlocks /> + </div> + ); + }, + + save() { + return ( + <div> + <InnerBlocks.Content /> + </div> + ); + } +} ); +``` + +_Note:_ A block can render at most a single `InnerBlocks` and `InnerBlocks.Content` element in `edit` and `save` respectively. To create distinct arrangements of nested blocks, create a separate block type which renders its own `InnerBlocks` and assign as the sole `allowedBlocks` type. + +_Note:_ Because the save step will automatically apply props to the element returned by `save`, it is important to include the wrapping `div` in the above simple example even though we are applying no props of our own. In a real-world example, you may have your own attributes to apply to the saved markup, or sibling content adjacent to the rendered nested blocks. + +## Props + +### `allowedBlocks` +* **Type:** `Array<String>` + +Allowed blocks prop should contain an array of strings, each string should contain the identifier of a block. When allowedBlocks is set it is only possible to insert blocks part of the set specified in the array. + +```jsx +const ALLOWED_BLOCKS = [ 'core/image', 'core/paragraph' ]; +... +<InnerBlocks + allowedBlocks={ ALLOWED_BLOCKS } +/> +``` + +The previous code block creates an `InnerBlocks` area where only image and paragraph blocks can be inserted. + +Child blocks that have marked themselves as compatible are not excluded from the allowed blocks. Even if `allowedBlocks` doesn't specify a child block, a registered child block will still appear on the inserter for this block. + +```jsx +const ALLOWED_BLOCKS = []; +... +<InnerBlocks + allowedBlocks={ ALLOWED_BLOCKS } +/> +``` + +The previous code block restricts all blocks, so only child blocks explicitly registered as compatible with this block can be inserted. If no child blocks are available: it will be impossible to insert any inner blocks. + +### `template` +* **Type:** `Array<Array<Object>>` + +The template is defined as a list of block items. Such blocks can have predefined attributes, placeholder, content, etc. Block templates allow specifying a default initial state for an InnerBlocks area. +More information about templates can be found in [template docs](/docs/developers/block-api/block-templates.md). + +```jsx +const TEMPLATE = [ [ 'core/columns', {}, [ + [ 'core/column', {}, [ + [ 'core/image' ], + ] ], + [ 'core/column', {}, [ + [ 'core/paragraph', { placeholder: 'Enter side content...' } ], + ] ], +] ] ]; +... +<InnerBlocks + template={ TEMPLATE } +/> +``` + +The previous example creates an InnerBlocks area containing two columns one with an image and the other with a paragraph. + +### `__experimentalTemplateOptions` + +* **Type:** `Array<Object>` + +To present the user with a set of template choices for the inner blocks, you may provide an array of template options. + +A template option is an object consisting of the following properties: + +- `title` (`string`): A human-readable label which describes the template. +- `icon` (`WPElement|string`): An element or [Dashicon](https://developer.wordpress.org/resource/dashicons/) slug to show as a visual approximation of the template. +- `template` (`Array<Array>`): The template to apply when the option has been selected. See [`template` documentation](#template) for more information. + +For the template placeholder selection to be displayed, you must render `InnerBlocks` with a `template` prop value of `null`. You may track this as state of your component, updating its value when receiving the selected template via `__experimentalOnSelectTemplateOption`. + +```jsx +import { useState } from '@wordpress/element'; + +const TEMPLATE_OPTIONS = [ + { + title: 'Two Columns', + icon: <svg />, + template: [ + [ 'core/column', { width: 50 } ], + [ 'core/column', { width: 50 } ], + ], + }, + { + title: 'Three Columns', + icon: <svg />, + template: [ + [ 'core/column', { width: 33.33 } ], + [ 'core/column', { width: 33.33 } ], + [ 'core/column', { width: 33.33 } ], + ], + }, +]; + +function edit() { + const [ template, setTemplate ] = useState( null ); + + return ( + <InnerBlocks + template={ template } + __experimentalTemplateOptions={ TEMPLATE_OPTIONS } + __experimentalOnSelectTemplateOption={ setTemplate } + /> + ); +} +``` + +### `__experimentalOnSelectTemplateOption` + +* **Type:** `Function` + +Callback function invoked when the user makes a template selection, used in combination with the `__experimentalTemplateOptions` props. The selected template is passed as the first and only argument. The value of the template may be `undefined` if the `__experimentalAllowTemplateOptionSkip` prop is passed to `InnerBlocks` and the user opts to skip template selection. + +### `__experimentalAllowTemplateOptionSkip` + +* **Type:** `Boolean` +* **Default:** `false` + +Whether to include a button in the template selection placeholder to allow the user to skip selection, used in combination with the `__experimentalTemplateOptions` prop. When clicked, the `__experimentalOnSelectTemplateOption` callback will be passed an `undefined` value as the template. + +### `templateInsertUpdatesSelection` +* **Type:** `Boolean` +* **Default:** `true` + +If true when child blocks in the template are inserted the selection is updated. +If false the selection should not be updated when child blocks specified in the template are inserted. + +### `templateLock` +* **Type:** `String|Boolean` + +Template locking of `InnerBlocks` is similar to [Custom Post Type templates locking](/docs/developers/block-api/block-templates.md#locking). + +Template locking allows locking the `InnerBlocks` area for the current template. +*Options:* + +- `'all'` — prevents all operations. It is not possible to insert new blocks. Move existing blocks or delete them. +- `'insert'` — prevents inserting or removing blocks, but allows moving existing ones. +- `false` — prevents locking from being applied to an `InnerBlocks` area even if a parent block contains locking. ( Boolean ) + +If locking is not set in an `InnerBlocks` area: the locking of the parent `InnerBlocks` area is used. + +If the block is a top level block: the locking of the Custom Post Type is used. + +### `renderAppender` +* **Type:** `Function|false` +* **Default:** - `undefined`. When `renderAppender` is not specific the `<DefaultBlockAppender>` component is as a default. It automatically inserts whichever block is configured as the default block via `wp.blocks.setDefaultBlockName` (typically `paragraph`). If a `false` value is provider, no appender is rendered. + +A 'render prop' function that can be used to customize the block's appender. + +#### Notes +* For convenience two predefined appender components are exposed on `InnerBlocks` which can be consumed within the render function: + - `<InnerBlocks.ButtonBlockAppender />` - display a `+` (plus) icon button that, when clicked, displays the block picker menu. No default Block is inserted. + - `<InnerBlocks.DefaultBlockAppender />` - display the default block appender as set by `wp.blocks.setDefaultBlockName`. Typically this is the `paragraph` block. +* Consumers are also free to pass any valid render function. This provides the full flexibility to define a bespoke block appender. + +#### Example usage + +```jsx +// Utilise a predefined component +<InnerBlocks + renderAppender={ () => ( + <InnerBlocks.ButtonBlockAppender /> + ) } +/> + +// Fully custom +<InnerBlocks + renderAppender={ () => ( + <button className="bespoke-appender" type="button">Some Special Appender</button> + ) } +/> +``` + + + + diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/button-block-appender.js b/gb-src/packages/block-editor/src/components/inner-blocks/button-block-appender.js new file mode 100644 index 0000000000000..af4f355e861ef --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/button-block-appender.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import BaseButtonBlockAppender from '../button-block-appender'; +import withClientId from './with-client-id'; + +export const ButtonBlockAppender = ( { clientId } ) => { + return ( + <BaseButtonBlockAppender rootClientId={ clientId } /> + ); +}; + +export default withClientId( ButtonBlockAppender ); diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/default-block-appender.js b/gb-src/packages/block-editor/src/components/inner-blocks/default-block-appender.js new file mode 100644 index 0000000000000..1aa6dbc8548b8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/default-block-appender.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { last } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import IgnoreNestedEvents from '../ignore-nested-events'; +import BaseDefaultBlockAppender from '../default-block-appender'; +import withClientId from './with-client-id'; + +export const DefaultBlockAppender = ( { clientId, lastBlockClientId } ) => { + return ( + <IgnoreNestedEvents childHandledEvents={ [ 'onFocus', 'onClick', 'onKeyDown' ] }> + <BaseDefaultBlockAppender + rootClientId={ clientId } + lastBlockClientId={ lastBlockClientId } + /> + </IgnoreNestedEvents> + ); +}; + +export default compose( [ + withClientId, + withSelect( ( select, { clientId } ) => { + const { + getBlockOrder, + } = select( 'core/block-editor' ); + + const blockClientIds = getBlockOrder( clientId ); + + return { + lastBlockClientId: last( blockClientIds ), + }; + } ), +] )( DefaultBlockAppender ); diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/index.js b/gb-src/packages/block-editor/src/components/inner-blocks/index.js new file mode 100644 index 0000000000000..35e1bf10b47ca --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/index.js @@ -0,0 +1,193 @@ +/** + * External dependencies + */ +import { pick, isEqual } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withViewportMatch } from '@wordpress/viewport'; // Temporary click-through disable on desktop. +import { Component } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { synchronizeBlocksWithTemplate, withBlockContentContext } from '@wordpress/blocks'; +import isShallowEqual from '@wordpress/is-shallow-equal'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import ButtonBlockAppender from './button-block-appender'; +import DefaultBlockAppender from './default-block-appender'; + +/** + * Internal dependencies + */ +import BlockList from '../block-list'; +import { withBlockEditContext } from '../block-edit/context'; +import TemplatePicker from './template-picker'; + +class InnerBlocks extends Component { + constructor() { + super( ...arguments ); + this.state = { + templateInProcess: !! this.props.template, + }; + this.updateNestedSettings(); + } + + componentDidMount() { + const { templateLock, block } = this.props; + const { innerBlocks } = block; + // Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block. + if ( innerBlocks.length === 0 || templateLock === 'all' ) { + this.synchronizeBlocksWithTemplate(); + } + + if ( this.state.templateInProcess ) { + this.setState( { + templateInProcess: false, + } ); + } + } + + componentDidUpdate( prevProps ) { + const { template, block, templateLock } = this.props; + const { innerBlocks } = block; + + this.updateNestedSettings(); + // Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block. + if ( innerBlocks.length === 0 || templateLock === 'all' ) { + const hasTemplateChanged = ! isEqual( template, prevProps.template ); + if ( hasTemplateChanged ) { + this.synchronizeBlocksWithTemplate(); + } + } + } + + /** + * Called on mount or when a mismatch exists between the templates and + * inner blocks, synchronizes inner blocks with the template, replacing + * current blocks. + */ + synchronizeBlocksWithTemplate() { + const { template, block, replaceInnerBlocks } = this.props; + const { innerBlocks } = block; + + // Synchronize with templates. If the next set differs, replace. + const nextBlocks = synchronizeBlocksWithTemplate( innerBlocks, template ); + if ( ! isEqual( nextBlocks, innerBlocks ) ) { + replaceInnerBlocks( nextBlocks ); + } + } + + updateNestedSettings() { + const { + blockListSettings, + allowedBlocks, + updateNestedSettings, + templateLock, + parentLock, + } = this.props; + + const newSettings = { + allowedBlocks, + templateLock: templateLock === undefined ? parentLock : templateLock, + }; + + if ( ! isShallowEqual( blockListSettings, newSettings ) ) { + updateNestedSettings( newSettings ); + } + } + + render() { + const { + isSmallScreen, // Temporary click-through disable on desktop. + clientId, + hasOverlay, + renderAppender, + template, + __experimentalTemplateOptions: templateOptions, + __experimentalOnSelectTemplateOption: onSelectTemplateOption, + __experimentalAllowTemplateOptionSkip: allowTemplateOptionSkip, + } = this.props; + const { templateInProcess } = this.state; + + const isPlaceholder = template === null && !! templateOptions; + + const classes = classnames( 'editor-inner-blocks block-editor-inner-blocks', { + 'has-overlay': isSmallScreen && ( hasOverlay && ! isPlaceholder ), // Temporary click-through disable on desktop. + } ); + + return ( + <div className={ classes }> + { ! templateInProcess && ( + isPlaceholder ? + <TemplatePicker + options={ templateOptions } + onSelect={ onSelectTemplateOption } + allowSkip={ allowTemplateOptionSkip } + /> : + <BlockList + rootClientId={ clientId } + renderAppender={ renderAppender } + /> + ) } + </div> + ); + } +} + +InnerBlocks = compose( [ + withViewportMatch( { isSmallScreen: '< medium' } ), // Temporary click-through disable on desktop. + withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) ), + withSelect( ( select, ownProps ) => { + const { + isBlockSelected, + hasSelectedInnerBlock, + getBlock, + getBlockListSettings, + getBlockRootClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const { clientId } = ownProps; + const block = getBlock( clientId ); + const rootClientId = getBlockRootClientId( clientId ); + + return { + block, + blockListSettings: getBlockListSettings( clientId ), + hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ), + parentLock: getTemplateLock( rootClientId ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { + replaceInnerBlocks, + updateBlockListSettings, + } = dispatch( 'core/block-editor' ); + const { block, clientId, templateInsertUpdatesSelection = true } = ownProps; + + return { + replaceInnerBlocks( blocks ) { + replaceInnerBlocks( clientId, blocks, block.innerBlocks.length === 0 && templateInsertUpdatesSelection ); + }, + updateNestedSettings( settings ) { + dispatch( updateBlockListSettings( clientId, settings ) ); + }, + }; + } ), +] )( InnerBlocks ); + +// Expose default appender placeholders as components. +InnerBlocks.DefaultBlockAppender = DefaultBlockAppender; +InnerBlocks.ButtonBlockAppender = ButtonBlockAppender; + +InnerBlocks.Content = withBlockContentContext( + ( { BlockContent } ) => <BlockContent /> +); + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inner-blocks/README.md + */ +export default InnerBlocks; diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/style.scss b/gb-src/packages/block-editor/src/components/inner-blocks/style.scss new file mode 100644 index 0000000000000..e7bd479e04e02 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/style.scss @@ -0,0 +1,94 @@ +// Add clickable overlay to blocks with nesting. +// This makes it easy to select all layers of the block. +.block-editor-inner-blocks.has-overlay { + &::after { + content: ""; + position: absolute; + top: -$block-padding; + right: -$block-padding; + bottom: -$block-padding; + left: -$block-padding; + z-index: z-index(".block-editor-inner-blocks.has-overlay::after"); + } +} + +// On fullwide blocks, don't go beyond the canvas. +[data-align="full"] > .editor-block-list__block-edit > [data-block] .has-overlay::after { + right: 0; + left: 0; +} + +.block-editor-inner-blocks__template-picker { + .components-placeholder__instructions { + // Defer to vertical margins applied by template picker options. + margin-bottom: 0; + } + + .components-placeholder__fieldset { + // Options will render horizontally, but the immediate children of the + // fieldset are the options and the skip button, oriented vertically. + flex-direction: column; + } + + &.has-many-options .components-placeholder__fieldset { + // Allow options to occupy a greater amount of the available space if + // many options exist. + max-width: 90%; + } +} + +.block-editor-inner-blocks__template-picker-options.block-editor-inner-blocks__template-picker-options { + display: flex; + justify-content: center; + flex-direction: row; + flex-wrap: wrap; + width: 100%; + margin: $grid-size-small 0; + list-style: none; + + > li { + list-style: none; + margin: $grid-size; + flex-shrink: 1; + max-width: 100px; + } + + .block-editor-inner-blocks__template-picker-option { + padding: $grid-size; + } +} + +.block-editor-inner-blocks__template-picker-option { + width: 100%; + + &.components-icon-button { + // Override default styles inherited from <IconButton /> to center + // icon horizontally. + justify-content: center; + + &.is-default { + background-color: $white; + } + } + + &.components-button { + // Override default styles inherited from <Button /> to allow button + // to grow vertically. + height: auto; + padding: 0; + } + + &::before { + // Use `padding-bottom` trick to style element as perfect square. + content: ""; + padding-bottom: 100%; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } +} diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/template-picker.js b/gb-src/packages/block-editor/src/components/inner-blocks/template-picker.js new file mode 100644 index 0000000000000..8afc65a34e25e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/template-picker.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, IconButton, Placeholder } from '@wordpress/components'; + +function InnerBlocksTemplatePicker( { + options, + onSelect, + allowSkip, +} ) { + const classes = classnames( 'block-editor-inner-blocks__template-picker', { + 'has-many-options': options.length > 4, + } ); + + const instructions = allowSkip ? + __( 'Select a layout to start with, or make one yourself.' ) : + __( 'Select a layout to start with.' ); + + return ( + <Placeholder + icon="layout" + label={ __( 'Choose Layout' ) } + instructions={ instructions } + className={ classes } + > + { + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ + } + <ul className="block-editor-inner-blocks__template-picker-options" role="list"> + { options.map( ( templateOption, index ) => ( + <li key={ index }> + <IconButton + isLarge + icon={ templateOption.icon } + onClick={ () => onSelect( templateOption.template ) } + className="block-editor-inner-blocks__template-picker-option" + label={ templateOption.title } + /> + </li> + ) ) } + </ul> + { /* eslint-enable jsx-a11y/no-redundant-roles */ } + { allowSkip && ( + <div className="block-editor-inner-blocks__template-picker-skip"> + <Button + isLink + onClick={ () => onSelect( undefined ) } + > + { __( 'Skip' ) } + </Button> + </div> + ) } + </Placeholder> + ); +} + +export default InnerBlocksTemplatePicker; diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/inner-blocks/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..5766351dc3c23 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/test/__snapshots__/index.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InnerBlocks should force serialize for invalid block with inner blocks 1`] = ` +"<!-- wp:test-block --> +<p>Invalid<!-- wp:test-block --> +<p></p> +<!-- /wp:test-block --></p> +<!-- /wp:test-block -->" +`; + +exports[`InnerBlocks should return element as string, with inner blocks 1`] = ` +"<div>Bananas<!-- wp:fruit {\\"fruit\\":\\"Apples\\"} --> +<div>Apples</div> +<!-- /wp:fruit --></div>" +`; diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/test/index.js b/gb-src/packages/block-editor/src/components/inner-blocks/test/index.js new file mode 100644 index 0000000000000..19bfd1bc383d7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/test/index.js @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getBlockTypes, + getSaveElement, + registerBlockType, + serialize, + unregisterBlockType, +} from '@wordpress/blocks'; +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import InnerBlocks from '../'; + +describe( 'InnerBlocks', () => { + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'should return element as string, with inner blocks', () => { + registerBlockType( 'core/fruit', { + category: 'common', + + title: 'fruit', + + attributes: { + fruit: { + type: 'string', + }, + }, + + supports: { + className: false, + }, + + save( { attributes } ) { + return ( + <div> + { attributes.fruit } + <InnerBlocks.Content /> + </div> + ); + }, + } ); + + const saved = renderToString( + getSaveElement( + 'core/fruit', + { fruit: 'Bananas' }, + [ createBlock( 'core/fruit', { fruit: 'Apples' } ) ], + ) + ); + + expect( saved ).toMatchSnapshot(); + } ); + + it( 'should force serialize for invalid block with inner blocks', () => { + const blockType = { + attributes: { + throw: { + type: 'boolean', + }, + defaulted: { + type: 'boolean', + default: false, + }, + content: { + type: 'string', + source: 'text', + }, + stuff: { + type: 'string', + }, + }, + save( { attributes } ) { + if ( attributes.throw ) { + throw new Error(); + } + + return ( + <p> + { attributes.content } + <InnerBlocks.Content /> + </p> + ); + }, + category: 'common', + title: 'block title', + }; + registerBlockType( 'core/test-block', blockType ); + const block = createBlock( + 'core/test-block', + { content: 'Invalid' }, + [ createBlock( 'core/test-block' ) ] + ); + + block.isValid = false; + block.originalContent = 'Original'; + + expect( serialize( block ) ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/inner-blocks/with-client-id.js b/gb-src/packages/block-editor/src/components/inner-blocks/with-client-id.js new file mode 100644 index 0000000000000..35672ba1681f2 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inner-blocks/with-client-id.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { pick } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { withBlockEditContext } from '../block-edit/context'; + +const withClientId = createHigherOrderComponent( + ( WrappedComponent ) => withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) )( WrappedComponent ), + 'withClientId' +); + +export default withClientId; diff --git a/gb-src/packages/block-editor/src/components/inserter-list-item/index.js b/gb-src/packages/block-editor/src/components/inserter-list-item/index.js new file mode 100644 index 0000000000000..a4af815ac3644 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter-list-item/index.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function InserterListItem( { + icon, + onClick, + isDisabled, + title, + className, + ...props +} ) { + const itemIconStyle = icon ? { + backgroundColor: icon.background, + color: icon.foreground, + } : {}; + + return ( + <li className="editor-block-types-list__list-item block-editor-block-types-list__list-item"> + <button + className={ + classnames( + 'editor-block-types-list__item block-editor-block-types-list__item', + className + ) + } + onClick={ ( event ) => { + event.preventDefault(); + onClick(); + } } + disabled={ isDisabled } + { ...props } + > + <span + className="editor-block-types-list__item-icon block-editor-block-types-list__item-icon" + style={ itemIconStyle } + > + <BlockIcon icon={ icon } showColors /> + </span> + <span className="editor-block-types-list__item-title block-editor-block-types-list__item-title"> + { title } + </span> + </button> + </li> + ); +} + +export default InserterListItem; diff --git a/gb-src/packages/block-editor/src/components/inserter-list-item/style.scss b/gb-src/packages/block-editor/src/components/inserter-list-item/style.scss new file mode 100644 index 0000000000000..8dc8b8084ad4f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter-list-item/style.scss @@ -0,0 +1,94 @@ +.block-editor-block-types-list__list-item { + display: block; + width: 33.33%; + padding: 0; + margin: 0 0 12px; +} + +.block-editor-block-types-list__item { + display: flex; + flex-direction: column; + width: 100%; + font-size: $default-font-size; + color: $dark-gray-700; + padding: 0 4px; + align-items: stretch; + justify-content: center; + cursor: pointer; + background: transparent; + word-break: break-word; + border-radius: $radius-round-rectangle; + border: $border-width solid transparent; + transition: all 0.05s ease-in-out; + @include reduce-motion("transition"); + position: relative; + + &:disabled { + @include block-style__disabled(); + } + + &:not(:disabled) { + + // Show the hover background in a pseudo selector so it can be below "stacked" icons, like Columns. + &:hover::before { + content: ""; + display: block; + @include block-style__hover(); + position: absolute; + z-index: -1; + border-radius: $radius-round-rectangle; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &:hover { + .block-editor-block-types-list__item-icon, + .block-editor-block-types-list__item-title { + color: inherit; + } + } + + &:active, + &:focus { + position: relative; + @include block-style__focus(); + + .block-editor-block-types-list__item-icon, + .block-editor-block-types-list__item-title { + color: inherit; + } + } + + &.is-active { + @include block-style__is-active(); + + &:focus { + @include block-style__is-active-focus(); + } + } + } +} + +.block-editor-block-types-list__item-icon { + padding: 12px 20px; + border-radius: $radius-round-rectangle; + color: $dark-gray-500; + transition: all 0.05s ease-in-out; + @include reduce-motion("transition"); + + .block-editor-block-icon { + margin-left: auto; + margin-right: auto; + } + + svg { + transition: all 0.15s ease-out; + @include reduce-motion("transition"); + } +} + +.block-editor-block-types-list__item-title { + padding: 4px 2px 8px; +} diff --git a/gb-src/packages/block-editor/src/components/inserter-menu-extension/index.js b/gb-src/packages/block-editor/src/components/inserter-menu-extension/index.js new file mode 100644 index 0000000000000..bc493945d9f36 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter-menu-extension/index.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +const { Fill: __experimentalInserterMenuExtension, Slot } = createSlotFill( '__experimentalInserterMenuExtension' ); + +__experimentalInserterMenuExtension.Slot = Slot; + +export default __experimentalInserterMenuExtension; diff --git a/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/index.js b/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/index.js new file mode 100644 index 0000000000000..71adf33922bff --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/index.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { filter, isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { IconButton } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function InserterWithShortcuts( { items, isLocked, onInsert } ) { + if ( isLocked ) { + return null; + } + + const itemsWithoutDefaultBlock = filter( items, ( item ) => { + return ! item.isDisabled && ( + item.name !== getDefaultBlockName() || + ! isEmpty( item.initialAttributes ) + ); + } ).slice( 0, 3 ); + + return ( + <div className="editor-inserter-with-shortcuts block-editor-inserter-with-shortcuts"> + { itemsWithoutDefaultBlock.map( ( item ) => ( + <IconButton + key={ item.id } + className="editor-inserter-with-shortcuts__block block-editor-inserter-with-shortcuts__block" + onClick={ () => onInsert( item ) } + // translators: %s: block title/name to be added + label={ sprintf( __( 'Add %s' ), item.title ) } + icon={ ( + <BlockIcon icon={ item.icon } /> + ) } + /> + ) ) } + </div> + ); +} + +export default compose( + withSelect( ( select, { rootClientId } ) => { + const { getInserterItems, getTemplateLock } = select( 'core/block-editor' ); + return { + items: getInserterItems( rootClientId ), + isLocked: !! getTemplateLock( rootClientId ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { clientId, rootClientId } = ownProps; + + return { + onInsert( { name, initialAttributes } ) { + const block = createBlock( name, initialAttributes ); + if ( clientId ) { + dispatch( 'core/block-editor' ).replaceBlocks( clientId, block ); + } else { + dispatch( 'core/block-editor' ).insertBlock( block, undefined, rootClientId ); + } + }, + }; + } ), +)( InserterWithShortcuts ); diff --git a/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/style.scss b/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/style.scss new file mode 100644 index 0000000000000..ef2d9e82ccebe --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter-with-shortcuts/style.scss @@ -0,0 +1,26 @@ +.block-editor-inserter-with-shortcuts { + display: flex; + align-items: center; + + .components-icon-button { + border-radius: $radius-round-rectangle; + + svg:not(.dashicon) { + height: 24px; + width: 24px; + } + } +} + +.block-editor-inserter-with-shortcuts__block { + margin-right: $block-spacing; + width: $icon-button-size; + height: $icon-button-size; + padding-top: 8px; + + // Use opacity to work in various editor styles. + color: $dark-opacity-500; + .is-dark-theme & { + color: $light-opacity-500; + } +} diff --git a/gb-src/packages/block-editor/src/components/inserter/child-blocks.js b/gb-src/packages/block-editor/src/components/inserter/child-blocks.js new file mode 100644 index 0000000000000..53590af3a1ee7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/child-blocks.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { ifCondition, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockTypesList from '../block-types-list'; +import BlockIcon from '../block-icon'; + +function ChildBlocks( { rootBlockIcon, rootBlockTitle, items, ...props } ) { + return ( + <div className="editor-inserter__child-blocks block-editor-inserter__child-blocks"> + { ( rootBlockIcon || rootBlockTitle ) && ( + <div className="editor-inserter__parent-block-header block-editor-inserter__parent-block-header"> + <BlockIcon icon={ rootBlockIcon } showColors /> + { rootBlockTitle && <h2>{ rootBlockTitle }</h2> } + </div> + ) } + <BlockTypesList items={ items } { ...props } /> + </div> + ); +} + +export default compose( + ifCondition( ( { items } ) => items && items.length > 0 ), + withSelect( ( select, { rootClientId } ) => { + const { + getBlockType, + } = select( 'core/blocks' ); + const { + getBlockName, + } = select( 'core/block-editor' ); + const rootBlockName = getBlockName( rootClientId ); + const rootBlockType = getBlockType( rootBlockName ); + return { + rootBlockTitle: rootBlockType && rootBlockType.title, + rootBlockIcon: rootBlockType && rootBlockType.icon, + }; + } ), +)( ChildBlocks ); diff --git a/gb-src/packages/block-editor/src/components/inserter/index.js b/gb-src/packages/block-editor/src/components/inserter/index.js new file mode 100644 index 0000000000000..5abb1ae4ef923 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/index.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dropdown, IconButton } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { compose, ifCondition } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import InserterMenu from './menu'; + +const defaultRenderToggle = ( { onToggle, disabled, isOpen } ) => ( + <IconButton + icon="insert" + label={ __( 'Add block' ) } + labelPosition="bottom" + onClick={ onToggle } + className="editor-inserter__toggle block-editor-inserter__toggle" + aria-haspopup="true" + aria-expanded={ isOpen } + disabled={ disabled } + /> +); + +class Inserter extends Component { + constructor() { + super( ...arguments ); + + this.onToggle = this.onToggle.bind( this ); + this.renderToggle = this.renderToggle.bind( this ); + this.renderContent = this.renderContent.bind( this ); + } + + onToggle( isOpen ) { + const { onToggle } = this.props; + + // Surface toggle callback to parent component + if ( onToggle ) { + onToggle( isOpen ); + } + } + + /** + * Render callback to display Dropdown toggle element. + * + * @param {Object} options + * @param {Function} options.onToggle Callback to invoke when toggle is + * pressed. + * @param {boolean} options.isOpen Whether dropdown is currently open. + * + * @return {WPElement} Dropdown toggle element. + */ + renderToggle( { onToggle, isOpen } ) { + const { + disabled, + renderToggle = defaultRenderToggle, + } = this.props; + + return renderToggle( { onToggle, isOpen, disabled } ); + } + + /** + * Render callback to display Dropdown content element. + * + * @param {Object} options + * @param {Function} options.onClose Callback to invoke when dropdown is + * closed. + * + * @return {WPElement} Dropdown content element. + */ + renderContent( { onClose } ) { + const { rootClientId, clientId, isAppender, showInserterHelpPanel } = this.props; + + return ( + <InserterMenu + onSelect={ onClose } + rootClientId={ rootClientId } + clientId={ clientId } + isAppender={ isAppender } + showInserterHelpPanel={ showInserterHelpPanel } + /> + ); + } + + render() { + const { position } = this.props; + + return ( + <Dropdown + className="editor-inserter block-editor-inserter" + contentClassName="editor-inserter__popover block-editor-inserter__popover" + position={ position } + onToggle={ this.onToggle } + expandOnMobile + headerTitle={ __( 'Add a block' ) } + renderToggle={ this.renderToggle } + renderContent={ this.renderContent } + /> + ); + } +} + +export default compose( [ + withSelect( ( select, { rootClientId } ) => { + const { hasInserterItems } = select( 'core/block-editor' ); + + return { + hasItems: hasInserterItems( rootClientId ), + }; + } ), + ifCondition( ( { hasItems } ) => hasItems ), +] )( Inserter ); diff --git a/gb-src/packages/block-editor/src/components/inserter/index.native.js b/gb-src/packages/block-editor/src/components/inserter/index.native.js new file mode 100644 index 0000000000000..b20eb8387992d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/index.native.js @@ -0,0 +1,121 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dropdown, ToolbarButton, Dashicon } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { getUnregisteredTypeHandlerName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; +import InserterMenu from './menu'; + +const defaultRenderToggle = ( { onToggle, disabled } ) => ( + <ToolbarButton + title={ __( 'Add block' ) } + icon={ ( <Dashicon icon="plus-alt" style={ styles.addBlockButton } color={ styles.addBlockButton.color } /> ) } + onClick={ onToggle } + extraProps={ { hint: __( 'Double tap to add a block' ) } } + isDisabled={ disabled } + /> +); + +class Inserter extends Component { + constructor() { + super( ...arguments ); + + this.onToggle = this.onToggle.bind( this ); + this.renderToggle = this.renderToggle.bind( this ); + this.renderContent = this.renderContent.bind( this ); + } + + onToggle( isOpen ) { + const { onToggle } = this.props; + + // Surface toggle callback to parent component + if ( onToggle ) { + onToggle( isOpen ); + } + } + + /** + * Render callback to display Dropdown toggle element. + * + * @param {Object} options + * @param {Function} options.onToggle Callback to invoke when toggle is + * pressed. + * @param {boolean} options.isOpen Whether dropdown is currently open. + * + * @return {WPElement} Dropdown toggle element. + */ + renderToggle( { onToggle, isOpen } ) { + const { + disabled, + renderToggle = defaultRenderToggle, + } = this.props; + + return renderToggle( { onToggle, isOpen, disabled } ); + } + + /** + * Render callback to display Dropdown content element. + * + * @param {Object} options + * @param {Function} options.onClose Callback to invoke when dropdown is + * closed. + * + * @return {WPElement} Dropdown content element. + */ + renderContent( { onClose, isOpen } ) { + const { rootClientId, clientId, isAppender } = this.props; + + return ( + <InserterMenu + isOpen={ isOpen } + onSelect={ onClose } + onDismiss={ onClose } + rootClientId={ rootClientId } + clientId={ clientId } + isAppender={ isAppender } + /> + ); + } + + render() { + return ( + <Dropdown + onToggle={ this.onToggle } + headerTitle={ __( 'Add a block' ) } + renderToggle={ this.renderToggle } + renderContent={ this.renderContent } + /> + ); + } +} + +export default compose( [ + withSelect( ( select, { clientId, isAppender, rootClientId } ) => { + const { + getInserterItems, + getBlockRootClientId, + getBlockSelectionEnd, + } = select( 'core/block-editor' ); + + let destinationRootClientId = rootClientId; + if ( ! destinationRootClientId && ! clientId && ! isAppender ) { + const end = getBlockSelectionEnd(); + if ( end ) { + destinationRootClientId = getBlockRootClientId( end ) || undefined; + } + } + const inserterItems = getInserterItems( destinationRootClientId ); + + return { + items: inserterItems.filter( ( { name } ) => name !== getUnregisteredTypeHandlerName() ), + }; + } ), +] )( Inserter ); diff --git a/gb-src/packages/block-editor/src/components/inserter/menu.js b/gb-src/packages/block-editor/src/components/inserter/menu.js new file mode 100644 index 0000000000000..7522c55369865 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/menu.js @@ -0,0 +1,556 @@ +/** + * External dependencies + */ +import { + filter, + find, + findIndex, + flow, + groupBy, + isEmpty, + map, + some, + sortBy, + without, + includes, + deburr, +} from 'lodash'; +import scrollIntoView from 'dom-scroll-into-view'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, _n, _x, sprintf } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { + PanelBody, + withSpokenMessages, + Tip, +} from '@wordpress/components'; +import { + getCategories, + isReusableBlock, + createBlock, + isUnmodifiedDefaultBlock, + getBlockType, + getBlockFromExample, +} from '@wordpress/blocks'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { withInstanceId, compose, withSafeTimeout } from '@wordpress/compose'; +import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import BlockPreview from '../block-preview'; +import BlockTypesList from '../block-types-list'; +import BlockCard from '../block-card'; +import ChildBlocks from './child-blocks'; +import __experimentalInserterMenuExtension from '../inserter-menu-extension'; + +const MAX_SUGGESTED_ITEMS = 9; + +const stopKeyPropagation = ( event ) => event.stopPropagation(); + +/** + * Filters an item list given a search term. + * + * @param {Array} items Item list + * @param {string} searchTerm Search term. + * + * @return {Array} Filtered item list. + */ +export const searchItems = ( items, searchTerm ) => { + const normalizedSearchTerm = normalizeTerm( searchTerm ); + const matchSearch = ( string ) => normalizeTerm( string ).indexOf( normalizedSearchTerm ) !== -1; + const categories = getCategories(); + + return items.filter( ( item ) => { + const itemCategory = find( categories, { slug: item.category } ); + return matchSearch( item.title ) || some( item.keywords, matchSearch ) || ( itemCategory && matchSearch( itemCategory.title ) ); + } ); +}; + +/** + * Converts the search term into a normalized term. + * + * @param {string} term The search term to normalize. + * + * @return {string} The normalized search term. + */ +export const normalizeTerm = ( term ) => { + // Disregard diacritics. + // Input: "média" + term = deburr( term ); + + // Accommodate leading slash, matching autocomplete expectations. + // Input: "/media" + term = term.replace( /^\//, '' ); + + // Lowercase. + // Input: "MEDIA" + term = term.toLowerCase(); + + // Strip leading and trailing whitespace. + // Input: " media " + term = term.trim(); + + return term; +}; + +export class InserterMenu extends Component { + constructor() { + super( ...arguments ); + this.state = { + childItems: [], + filterValue: '', + hoveredItem: null, + suggestedItems: [], + reusableItems: [], + itemsPerCategory: {}, + openPanels: [ 'suggested' ], + }; + this.onChangeSearchInput = this.onChangeSearchInput.bind( this ); + this.onHover = this.onHover.bind( this ); + this.panels = {}; + this.inserterResults = createRef(); + } + + componentDidMount() { + // This could be replaced by a resolver. + this.props.fetchReusableBlocks(); + this.filter(); + } + + componentDidUpdate( prevProps ) { + if ( prevProps.items !== this.props.items ) { + this.filter( this.state.filterValue ); + } + } + + onChangeSearchInput( event ) { + this.filter( event.target.value ); + } + + onHover( item ) { + this.setState( { + hoveredItem: item, + } ); + + const { showInsertionPoint, hideInsertionPoint } = this.props; + if ( item ) { + showInsertionPoint(); + } else { + hideInsertionPoint(); + } + } + + bindPanel( name ) { + return ( ref ) => { + this.panels[ name ] = ref; + }; + } + + onTogglePanel( panel ) { + return () => { + const isOpened = this.state.openPanels.indexOf( panel ) !== -1; + if ( isOpened ) { + this.setState( { + openPanels: without( this.state.openPanels, panel ), + } ); + } else { + this.setState( { + openPanels: [ + ...this.state.openPanels, + panel, + ], + } ); + + this.props.setTimeout( () => { + // We need a generic way to access the panel's container + scrollIntoView( this.panels[ panel ], this.inserterResults.current, { + alignWithTop: true, + } ); + } ); + } + }; + } + + filterOpenPanels( filterValue, itemsPerCategory, filteredItems, reusableItems ) { + if ( filterValue === this.state.filterValue ) { + return this.state.openPanels; + } + if ( ! filterValue ) { + return [ 'suggested' ]; + } + let openPanels = []; + if ( reusableItems.length > 0 ) { + openPanels.push( 'reusable' ); + } + if ( filteredItems.length > 0 ) { + openPanels = openPanels.concat( + Object.keys( itemsPerCategory ) + ); + } + return openPanels; + } + + filter( filterValue = '' ) { + const { debouncedSpeak, items, rootChildBlocks } = this.props; + + const filteredItems = searchItems( items, filterValue ); + + const childItems = filter( filteredItems, ( { name } ) => includes( rootChildBlocks, name ) ); + + let suggestedItems = []; + if ( ! filterValue ) { + const maxSuggestedItems = this.props.maxSuggestedItems || MAX_SUGGESTED_ITEMS; + suggestedItems = filter( items, ( item ) => item.utility > 0 ).slice( 0, maxSuggestedItems ); + } + + const reusableItems = filter( filteredItems, { category: 'reusable' } ); + + const getCategoryIndex = ( item ) => { + return findIndex( getCategories(), ( category ) => category.slug === item.category ); + }; + const itemsPerCategory = flow( + ( itemList ) => filter( itemList, ( item ) => item.category !== 'reusable' ), + ( itemList ) => sortBy( itemList, getCategoryIndex ), + ( itemList ) => groupBy( itemList, 'category' ) + )( filteredItems ); + + this.setState( { + hoveredItem: null, + childItems, + filterValue, + suggestedItems, + reusableItems, + itemsPerCategory, + openPanels: this.filterOpenPanels( + filterValue, + itemsPerCategory, + filteredItems, + reusableItems + ), + } ); + + const resultCount = Object.keys( itemsPerCategory ).reduce( ( accumulator, currentCategorySlug ) => { + return accumulator + itemsPerCategory[ currentCategorySlug ].length; + }, 0 ); + + const resultsFoundMessage = sprintf( + _n( '%d result found.', '%d results found.', resultCount ), + resultCount + ); + debouncedSpeak( resultsFoundMessage ); + } + + onKeyDown( event ) { + if ( includes( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ], event.keyCode ) ) { + // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. + event.stopPropagation(); + } + } + + render() { + const { instanceId, onSelect, rootClientId, showInserterHelpPanel } = this.props; + const { + childItems, + hoveredItem, + itemsPerCategory, + openPanels, + reusableItems, + suggestedItems, + filterValue, + } = this.state; + + const isPanelOpen = ( panel ) => openPanels.indexOf( panel ) !== -1; + const hasItems = ! isEmpty( suggestedItems ) || ! isEmpty( reusableItems ) || ! isEmpty( itemsPerCategory ); + const hoveredItemBlockType = hoveredItem ? getBlockType( hoveredItem.name ) : null; + const hasHelpPanel = hasItems && showInserterHelpPanel; + + // Disable reason (no-autofocus): The inserter menu is a modal display, not one which + // is always visible, and one which already incurs this behavior of autoFocus via + // Popover's focusOnMount. + // Disable reason (no-static-element-interactions): Navigational key-presses within + // the menu are prevented from triggering WritingFlow and ObserveTyping interactions. + /* eslint-disable jsx-a11y/no-autofocus, jsx-a11y/no-static-element-interactions */ + return ( + <div + className={ classnames( 'editor-inserter__menu block-editor-inserter__menu', { + 'has-help-panel': hasHelpPanel, + } ) } + onKeyPress={ stopKeyPropagation } + onKeyDown={ this.onKeyDown } + > + <div className="block-editor-inserter__main-area"> + <label htmlFor={ `block-editor-inserter__search-${ instanceId }` } className="screen-reader-text"> + { __( 'Search for a block' ) } + </label> + <input + id={ `block-editor-inserter__search-${ instanceId }` } + type="search" + placeholder={ __( 'Search for a block' ) } + className="editor-inserter__search block-editor-inserter__search" + autoFocus + onChange={ this.onChangeSearchInput } + /> + + <div + className="editor-inserter__results block-editor-inserter__results" + ref={ this.inserterResults } + tabIndex="0" + role="region" + aria-label={ __( 'Available block types' ) } + > + + <ChildBlocks + rootClientId={ rootClientId } + items={ childItems } + onSelect={ onSelect } + onHover={ this.onHover } + /> + + { !! suggestedItems.length && + <PanelBody + title={ _x( 'Most Used', 'blocks' ) } + opened={ isPanelOpen( 'suggested' ) } + onToggle={ this.onTogglePanel( 'suggested' ) } + ref={ this.bindPanel( 'suggested' ) } + > + <BlockTypesList items={ suggestedItems } onSelect={ onSelect } onHover={ this.onHover } /> + </PanelBody> + } + + { map( getCategories(), ( category ) => { + const categoryItems = itemsPerCategory[ category.slug ]; + if ( ! categoryItems || ! categoryItems.length ) { + return null; + } + return ( + <PanelBody + key={ category.slug } + title={ category.title } + icon={ category.icon } + opened={ isPanelOpen( category.slug ) } + onToggle={ this.onTogglePanel( category.slug ) } + ref={ this.bindPanel( category.slug ) } + > + <BlockTypesList items={ categoryItems } onSelect={ onSelect } onHover={ this.onHover } /> + </PanelBody> + ); + } ) } + + { !! reusableItems.length && ( + <PanelBody + className="editor-inserter__reusable-blocks-panel block-editor-inserter__reusable-blocks-panel" + title={ __( 'Reusable' ) } + opened={ isPanelOpen( 'reusable' ) } + onToggle={ this.onTogglePanel( 'reusable' ) } + icon="controls-repeat" + ref={ this.bindPanel( 'reusable' ) } + > + <BlockTypesList items={ reusableItems } onSelect={ onSelect } onHover={ this.onHover } /> + <a + className="editor-inserter__manage-reusable-blocks block-editor-inserter__manage-reusable-blocks" + href={ addQueryArgs( 'edit.php', { post_type: 'wp_block' } ) } + > + { __( 'Manage All Reusable Blocks' ) } + </a> + </PanelBody> + ) } + + <__experimentalInserterMenuExtension.Slot + fillProps={ { + onSelect, + onHover: this.onHover, + filterValue, + hasItems, + } } + > + { ( fills ) => { + if ( fills.length ) { + return fills; + } + if ( ! hasItems ) { + return ( + <p className="editor-inserter__no-results block-editor-inserter__no-results">{ __( 'No blocks found.' ) }</p> + ); + } + return null; + } } + </__experimentalInserterMenuExtension.Slot> + </div> + </div> + + { hasHelpPanel && ( + <div className="block-editor-inserter__menu-help-panel"> + { hoveredItem && ( + <> + { ! isReusableBlock( hoveredItem ) && ( + <BlockCard blockType={ hoveredItemBlockType } /> + ) } + <div className="block-editor-inserter__preview"> + { ( isReusableBlock( hoveredItem ) || hoveredItemBlockType.example ) ? ( + <div className="block-editor-inserter__preview-content"> + <BlockPreview + padding={ 10 } + viewportWidth={ 500 } + blocks={ + hoveredItemBlockType.example ? + getBlockFromExample( hoveredItem.name, hoveredItemBlockType.example ) : + createBlock( hoveredItem.name, hoveredItem.initialAttributes ) + } + /> + </div> + ) : ( + <div className="block-editor-inserter__preview-content-missing"> + { __( 'No Preview Available.' ) } + </div> + ) } + </div> + </> + ) } + { ! hoveredItem && ( + <div className="block-editor-inserter__menu-help-panel-no-block"> + <div className="block-editor-inserter__menu-help-panel-no-block-text"> + <div className="block-editor-inserter__menu-help-panel-title">{ __( 'Content Blocks' ) }</div> + <p> + { __( + 'Welcome to the wonderful world of blocks! Blocks are the basis of all content within the editor.' + ) } + </p> + <p> + { __( + 'There are blocks available for all kinds of content: insert text, headings, images, lists, videos, tables, and lots more.' + ) } + </p> + <p> + { __( + 'Browse through the library to learn more about what each block does.' + ) } + </p> + </div> + <Tip> + { __( + 'While writing, you can press "/" to quickly insert new blocks.' + ) } + </Tip> + </div> + ) } + </div> + ) } + </div> + ); + /* eslint-enable jsx-a11y/no-autofocus, jsx-a11y/no-static-element-interactions */ + } +} + +export default compose( + withSelect( ( select, { clientId, isAppender, rootClientId, showInserterHelpPanel } ) => { + const { + getInserterItems, + getBlockName, + getBlockRootClientId, + getBlockSelectionEnd, + getSettings, + } = select( 'core/block-editor' ); + const { + getChildBlockNames, + } = select( 'core/blocks' ); + + let destinationRootClientId = rootClientId; + if ( ! destinationRootClientId && ! clientId && ! isAppender ) { + const end = getBlockSelectionEnd(); + if ( end ) { + destinationRootClientId = getBlockRootClientId( end ) || undefined; + } + } + const destinationRootBlockName = getBlockName( destinationRootClientId ); + + return { + rootChildBlocks: getChildBlockNames( destinationRootBlockName ), + items: getInserterItems( destinationRootClientId ), + showInserterHelpPanel: showInserterHelpPanel && getSettings().showInserterHelpPanel, + destinationRootClientId, + }; + } ), + withDispatch( ( dispatch, ownProps, { select } ) => { + const { + showInsertionPoint, + hideInsertionPoint, + } = dispatch( 'core/block-editor' ); + + // This should be an external action provided in the editor settings. + const { + __experimentalFetchReusableBlocks: fetchReusableBlocks, + } = dispatch( 'core/editor' ); + + // To avoid duplication, getInsertionIndex is extracted and used in two event handlers + // This breaks the withDispatch not containing any logic rule. + // Since it's a function only called when the event handlers are called, + // it's fine to extract it. + // eslint-disable-next-line no-restricted-syntax + function getInsertionIndex() { + const { + getBlockIndex, + getBlockSelectionEnd, + getBlockOrder, + } = select( 'core/block-editor' ); + const { clientId, destinationRootClientId, isAppender } = ownProps; + + // If the clientId is defined, we insert at the position of the block. + if ( clientId ) { + return getBlockIndex( clientId, destinationRootClientId ); + } + + // If there a selected block, we insert after the selected block. + const end = getBlockSelectionEnd(); + if ( ! isAppender && end ) { + return getBlockIndex( end, destinationRootClientId ) + 1; + } + + // Otherwise, we insert at the end of the current rootClientId + return getBlockOrder( destinationRootClientId ).length; + } + + return { + fetchReusableBlocks, + showInsertionPoint() { + const index = getInsertionIndex(); + showInsertionPoint( ownProps.destinationRootClientId, index ); + }, + hideInsertionPoint, + onSelect( item ) { + const { + replaceBlocks, + insertBlock, + } = dispatch( 'core/block-editor' ); + const { + getSelectedBlock, + } = select( 'core/block-editor' ); + const { isAppender } = ownProps; + const { name, initialAttributes } = item; + const selectedBlock = getSelectedBlock(); + const insertedBlock = createBlock( name, initialAttributes ); + if ( ! isAppender && selectedBlock && isUnmodifiedDefaultBlock( selectedBlock ) ) { + replaceBlocks( selectedBlock.clientId, insertedBlock ); + } else { + insertBlock( + insertedBlock, + getInsertionIndex(), + ownProps.destinationRootClientId + ); + } + + ownProps.onSelect(); + return insertedBlock; + }, + }; + } ), + withSpokenMessages, + withInstanceId, + withSafeTimeout +)( InserterMenu ); diff --git a/gb-src/packages/block-editor/src/components/inserter/menu.native.js b/gb-src/packages/block-editor/src/components/inserter/menu.native.js new file mode 100644 index 0000000000000..7870f41954afe --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/menu.native.js @@ -0,0 +1,216 @@ +/** + * External dependencies + */ +import { FlatList, View, Text, TouchableHighlight } from 'react-native'; +import { subscribeMediaAppend } from 'react-native-gutenberg-bridge'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + createBlock, + isUnmodifiedDefaultBlock, +} from '@wordpress/blocks'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { BottomSheet, Icon } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export class InserterMenu extends Component { + componentDidMount() { + this.subscriptionParentMediaAppend = subscribeMediaAppend( ( payload ) => { + this.props.onSelect( { + name: 'core/' + payload.mediaType, + initialAttributes: { + id: payload.mediaId, + [ payload.mediaType === 'image' ? 'url' : 'src' ]: payload.mediaUrl, + }, + } ); + } ); + this.onOpen(); + } + + componentWillUnmount() { + if ( this.subscriptionParentMediaAppend ) { + this.subscriptionParentMediaAppend.remove(); + } + this.onClose(); + } + + calculateNumberOfColumns() { + const bottomSheetWidth = BottomSheet.getWidth(); + const { paddingLeft: itemPaddingLeft, paddingRight: itemPaddingRight } = styles.modalItem; + const { paddingLeft: containerPaddingLeft, paddingRight: containerPaddingRight } = styles.content; + const { width: itemWidth } = styles.modalIconWrapper; + const itemTotalWidth = itemWidth + itemPaddingLeft + itemPaddingRight; + const containerTotalWidth = bottomSheetWidth - ( containerPaddingLeft + containerPaddingRight ); + return Math.floor( containerTotalWidth / itemTotalWidth ); + } + + onOpen() { + this.props.showInsertionPoint(); + } + + onClose() { + this.props.hideInsertionPoint(); + } + + render() { + const numberOfColumns = this.calculateNumberOfColumns(); + const bottomPadding = styles.contentBottomPadding; + + return ( + <BottomSheet + isVisible={ true } + onClose={ this.props.onDismiss } + contentStyle={ [ styles.content, bottomPadding ] } + hideHeader + > + <FlatList + scrollEnabled={ false } + key={ `InserterUI-${ numberOfColumns }` } //re-render when numberOfColumns changes + keyboardShouldPersistTaps="always" + numColumns={ numberOfColumns } + data={ this.props.items } + ItemSeparatorComponent={ () => + <View style={ styles.rowSeparator } /> + } + keyExtractor={ ( item ) => item.name } + renderItem={ ( { item } ) => + <TouchableHighlight + style={ styles.touchableArea } + underlayColor="transparent" + activeOpacity={ .5 } + accessibilityLabel={ item.title } + onPress={ () => this.props.onSelect( item ) }> + <View style={ styles.modalItem }> + <View style={ styles.modalIconWrapper }> + <View style={ styles.modalIcon }> + <Icon icon={ item.icon.src } fill={ styles.modalIcon.fill } size={ styles.modalIcon.width } /> + </View> + </View> + <Text style={ styles.modalItemLabel }>{ item.title }</Text> + </View> + </TouchableHighlight> + } + /> + </BottomSheet> + ); + } +} + +export default compose( + withSelect( ( select, { clientId, isAppender, rootClientId } ) => { + const { + getInserterItems, + getBlockName, + getBlockRootClientId, + getBlockSelectionEnd, + } = select( 'core/block-editor' ); + const { + getChildBlockNames, + } = select( 'core/blocks' ); + + let destinationRootClientId = rootClientId; + if ( ! destinationRootClientId && ! clientId && ! isAppender ) { + const end = getBlockSelectionEnd(); + if ( end ) { + destinationRootClientId = getBlockRootClientId( end ) || undefined; + } + } + const destinationRootBlockName = getBlockName( destinationRootClientId ); + + return { + rootChildBlocks: getChildBlockNames( destinationRootBlockName ), + items: getInserterItems( destinationRootClientId ), + destinationRootClientId, + }; + } ), + withDispatch( ( dispatch, ownProps, { select } ) => { + const { + showInsertionPoint, + hideInsertionPoint, + } = dispatch( 'core/block-editor' ); + + // To avoid duplication, getInsertionIndex is extracted and used in two event handlers + // This breaks the withDispatch not containing any logic rule. + // Since it's a function only called when the event handlers are called, + // it's fine to extract it. + // eslint-disable-next-line no-restricted-syntax + function getInsertionIndex() { + const { + getBlock, + getBlockIndex, + getBlockSelectionEnd, + getBlockOrder, + } = select( 'core/block-editor' ); + const { + isPostTitleSelected, + } = select( 'core/editor' ); + const { clientId, destinationRootClientId, isAppender } = ownProps; + + // if post title is selected insert as first block + if ( isPostTitleSelected() ) { + return 0; + } + + // If the clientId is defined, we insert at the position of the block. + if ( clientId ) { + return getBlockIndex( clientId, destinationRootClientId ); + } + + // If there a selected block, + const end = getBlockSelectionEnd(); + if ( ! isAppender && end ) { + // and the last selected block is unmodified (empty), it will be replaced + if ( isUnmodifiedDefaultBlock( getBlock( end ) ) ) { + return getBlockIndex( end, destinationRootClientId ); + } + + // we insert after the selected block. + return getBlockIndex( end, destinationRootClientId ) + 1; + } + + // Otherwise, we insert at the end of the current rootClientId + return getBlockOrder( destinationRootClientId ).length; + } + + return { + showInsertionPoint() { + const index = getInsertionIndex(); + showInsertionPoint( ownProps.destinationRootClientId, index ); + }, + hideInsertionPoint, + onSelect( item ) { + const { + replaceBlocks, + insertBlock, + } = dispatch( 'core/block-editor' ); + const { + getSelectedBlock, + } = select( 'core/block-editor' ); + const { isAppender } = ownProps; + const { name, initialAttributes } = item; + const selectedBlock = getSelectedBlock(); + const insertedBlock = createBlock( name, initialAttributes ); + if ( ! isAppender && selectedBlock && isUnmodifiedDefaultBlock( selectedBlock ) ) { + replaceBlocks( selectedBlock.clientId, insertedBlock ); + } else { + insertBlock( + insertedBlock, + getInsertionIndex(), + ownProps.destinationRootClientId + ); + } + + ownProps.onSelect(); + }, + }; + } ), + withInstanceId, +)( InserterMenu ); diff --git a/gb-src/packages/block-editor/src/components/inserter/style.native.scss b/gb-src/packages/block-editor/src/components/inserter/style.native.scss new file mode 100644 index 0000000000000..e10b685dda406 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/style.native.scss @@ -0,0 +1,64 @@ +/** @format */ + +.touchableArea { + border-radius: 8px 8px 8px 8px; +} + +.content { + padding: 0 0 0 0; + align-items: center; + justify-content: space-evenly; +} + +.contentBottomPadding { + padding-bottom: 20px; +} + +.rowSeparator { + height: 12px; +} + +.modalItem { + flex-direction: column; + justify-content: center; + align-items: center; + padding-left: 8; + padding-right: 8; + padding-top: 0; + padding-bottom: 0; +} + +.modalIconWrapper { + width: 104px; + height: 64px; + background-color: $gray-light; //#f3f6f8 + border-radius: 8px 8px 8px 8px; + justify-content: center; + align-items: center; +} + +.modalIcon { + width: 32px; + height: 32px; + justify-content: center; + align-items: center; + fill: $gray-dark; +} + +.modalItemLabel { + background-color: transparent; + padding-left: 2; + padding-right: 2; + padding-top: 4; + padding-bottom: 0; + justify-content: center; + font-size: 12; + color: $gray-dark; +} + +.addBlockButton { + color: $blue-wordpress; + border: 2px; + border-radius: 10px; + border-color: $blue-wordpress; +} diff --git a/gb-src/packages/block-editor/src/components/inserter/style.scss b/gb-src/packages/block-editor/src/components/inserter/style.scss new file mode 100644 index 0000000000000..275091342cf4d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/style.scss @@ -0,0 +1,239 @@ +$block-inserter-content-height: 350px; +$block-inserter-tabs-height: 44px; +$block-inserter-search-height: 38px; + +.block-editor-inserter { + display: inline-block; + background: none; + border: none; + padding: 0; + font-family: $default-font; + font-size: $default-font-size; + line-height: $default-line-height; + + @include break-medium { + position: relative; + } +} + +.block-editor-inserter__popover:not(.is-mobile) > .components-popover__content { + @include break-medium { + overflow-y: visible; + height: $block-inserter-content-height + $block-inserter-tabs-height + $block-inserter-search-height; + } +} + +.block-editor-inserter__toggle { + display: inline-flex; + align-items: center; + color: $dark-gray-500; + background: none; + cursor: pointer; + border: none; + outline: none; + transition: color 0.2s ease; + @include reduce-motion("transition"); +} + +.block-editor-inserter__menu { + height: 100%; + display: flex; + width: auto; + + @include break-medium { + width: 400px; + position: relative; + + &.has-help-panel { + width: 700px; + } + } +} + +.block-editor-inserter__main-area { + width: auto; + display: flex; + flex-direction: column; + height: 100%; + @include break-medium { + width: 400px; + position: relative; + } +} + +.block-editor-inserter__inline-elements { + margin-top: -1px; +} + +.block-editor-inserter__menu.is-bottom::after { + border-bottom-color: $white; +} + +.components-popover.block-editor-inserter__popover { + z-index: z-index(".components-popover.block-editor-inserter__popover"); +} + +.components-popover input[type="search"].block-editor-inserter__search { + display: block; + margin: $grid-size-large; + padding: 11px $grid-size-large; + position: relative; + z-index: 1; + border-radius: $radius-round-rectangle; + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + @include break-small { + font-size: $default-font-size; + } + + &:focus { + @include input-style__focus(); + } +} + +.block-editor-inserter__results { + flex-grow: 1; + overflow: auto; + position: relative; + z-index: 1; // Necessary for the stacked card below parent blocks to show up. + padding: 0 $grid-size-large $grid-size-large $grid-size-large; + + &:focus { + outline: $border-width dotted $dark-gray-500; + } + + @include break-medium { + height: $block-inserter-content-height + $block-inserter-tabs-height; + } + + // Don't show the top border on the first panel, let the Search border be the border. + [role="presentation"] + .components-panel__body { + border-top: none; + } +} + +.block-editor-inserter__popover .block-editor-block-types-list { + margin: 0 -8px; +} + +.block-editor-inserter__reusable-blocks-panel { + position: relative; + text-align: right; +} + +.block-editor-inserter__manage-reusable-blocks { + margin: $grid-size-large 0 0 $grid-size-large; +} + +.block-editor-inserter__no-results { + font-style: italic; + padding: 24px; + text-align: center; +} + +.block-editor-inserter__child-blocks { + padding: 0 $grid-size-large; +} + +.block-editor-inserter__parent-block-header { + display: flex; + align-items: center; + + h2 { + font-size: 13px; + } + + .block-editor-block-icon { + margin-right: $grid-size; + } +} + +.block-editor-inserter__menu-help-panel { + display: none; + border-left: $border-width solid $light-gray-500; + width: 300px; + height: 100%; + padding: 20px; + overflow-y: auto; + + @include break-medium { + display: flex; + flex-direction: column; + } + + .block-editor-block-card { + padding-bottom: 20px; + margin-bottom: 20px; + border-bottom: $border-width solid $light-gray-500; + @include edit-post__fade-in-animation(); + } + + .block-editor-inserter__preview { + display: flex; + flex-grow: 2; + } +} + +.block-editor-inserter__menu-help-panel-no-block { + display: flex; + height: 100%; + flex-direction: column; + @include edit-post__fade-in-animation(); + + .block-editor-inserter__menu-help-panel-no-block-text { + flex-grow: 1; + + h4 { + font-size: $big-font-size; + } + } + + .components-notice { + margin: 0; + } + + h4 { + margin-top: 0; + } +} + +.block-editor-inserter__menu-help-panel-hover-area { + flex-grow: 1; + margin-top: 20px; + padding: 20px; + border: 1px dotted $light-gray-500; + display: flex; + align-items: center; + text-align: center; +} + +.block-editor-inserter__menu-help-panel-title { + font-size: $big-font-size; + font-weight: 600; + margin-bottom: 20px; +} + +.block-editor-inserter__preview-content { + border: $border-width solid $light-gray-500; + border-radius: $radius-round-rectangle; + min-height: 150px; + display: grid; + flex-grow: 2; + + .block-editor-block-preview__container { + margin-right: 0; + margin-left: 0; + padding: 10px; + } +} + +.block-editor-inserter__preview-content-missing { + flex: 1; + display: flex; + justify-content: center; + color: $dark-gray-400; + border: $border-width solid $light-gray-500; + border-radius: $radius-round-rectangle; + align-items: center; +} diff --git a/gb-src/packages/block-editor/src/components/inserter/test/menu.js b/gb-src/packages/block-editor/src/components/inserter/test/menu.js new file mode 100644 index 0000000000000..ef9d2b10214a9 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inserter/test/menu.js @@ -0,0 +1,371 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import TestUtils from 'react-dom/test-utils'; +import ReactDOM from 'react-dom'; + +/** + * Internal dependencies + */ +import { InserterMenu, searchItems, normalizeTerm } from '../menu'; + +const textItem = { + id: 'core/text-block', + name: 'core/text-block', + initialAttributes: {}, + title: 'Text', + category: 'common', + isDisabled: false, + utility: 1, +}; + +const advancedTextItem = { + id: 'core/advanced-text-block', + name: 'core/advanced-text-block', + initialAttributes: {}, + title: 'Advanced Text', + category: 'common', + isDisabled: false, + utility: 1, +}; + +const someOtherItem = { + id: 'core/some-other-block', + name: 'core/some-other-block', + initialAttributes: {}, + title: 'Some Other Block', + category: 'common', + isDisabled: false, + utility: 1, +}; + +const moreItem = { + id: 'core/more-block', + name: 'core/more-block', + initialAttributes: {}, + title: 'More', + category: 'layout', + isDisabled: true, + utility: 0, +}; + +const youtubeItem = { + id: 'core-embed/youtube', + name: 'core-embed/youtube', + initialAttributes: {}, + title: 'YouTube', + category: 'embed', + keywords: [ 'google' ], + isDisabled: false, + utility: 0, +}; + +const textEmbedItem = { + id: 'core-embed/a-text-embed', + name: 'core-embed/a-text-embed', + initialAttributes: {}, + title: 'A Text Embed', + category: 'embed', + isDisabled: false, + utility: 0, +}; + +const reusableItem = { + id: 'core/block/123', + name: 'core/block', + initialAttributes: { ref: 123 }, + title: 'My reusable block', + category: 'reusable', + isDisabled: false, + utility: 0, +}; + +const items = [ + textItem, + advancedTextItem, + someOtherItem, + moreItem, + youtubeItem, + textEmbedItem, + reusableItem, +]; + +const DEFAULT_PROPS = { + position: 'top center', + items, + debouncedSpeak: noop, + fetchReusableBlocks: noop, + setTimeout: noop, +}; + +const getWrapperForProps = ( propOverrides ) => { + return TestUtils.renderIntoDocument( + <InserterMenu { ...DEFAULT_PROPS } { ...propOverrides } /> + ); +}; + +const initializeMenuDefaultStateAndReturnElement = ( propOverrides ) => { + const wrapper = getWrapperForProps( propOverrides ); + // eslint-disable-next-line react/no-find-dom-node + return ReactDOM.findDOMNode( wrapper ); +}; + +const initializeAllClosedMenuStateAndReturnElement = ( propOverrides ) => { + const element = initializeMenuDefaultStateAndReturnElement( propOverrides ); + const activeTabs = element.querySelectorAll( + '.components-panel__body.is-opened button.components-panel__body-toggle' + ); + activeTabs.forEach( ( tab ) => { + TestUtils.Simulate.click( tab ); + } ); + return element; +}; + +const assertNoResultsMessageToBePresent = ( element ) => { + const noResultsMessage = element.querySelector( + '.block-editor-inserter__no-results' + ); + expect( noResultsMessage.textContent ).toEqual( 'No blocks found.' ); +}; + +const assertNoResultsMessageNotToBePresent = ( element ) => { + const noResultsMessage = element.querySelector( + '.block-editor-inserter__no-results' + ); + expect( noResultsMessage ).toBe( null ); +}; + +const assertOpenedPanels = ( element, expectedOpen = 0 ) => { + expect( element.querySelectorAll( '.components-panel__body.is-opened ' ) ) + .toHaveLength( expectedOpen ); +}; + +const getTabButtonWithContent = ( element, content ) => { + let foundButton; + const buttons = element.querySelectorAll( '.components-button' ); + buttons.forEach( ( button ) => { + if ( button.textContent === content ) { + foundButton = button; + } + } ); + return foundButton; +}; + +const performSearchWithText = ( element, searchText ) => { + const searchElement = element.querySelector( '.block-editor-inserter__search' ); + TestUtils.Simulate.change( searchElement, { target: { value: searchText } } ); +}; + +describe( 'InserterMenu', () => { + it( 'should show the suggested tab by default', () => { + const element = initializeMenuDefaultStateAndReturnElement(); + const activeCategory = element.querySelector( + '.components-panel__body.is-opened > .components-panel__body-title' + ); + expect( activeCategory.textContent ).toBe( 'Most Used' ); + } ); + + it( 'should show nothing if there are no items', () => { + const element = initializeMenuDefaultStateAndReturnElement( + { items: [] } + ); + const visibleBlocks = element.querySelector( + '.block-editor-block-types-list__item' + ); + + expect( visibleBlocks ).toBe( null ); + + assertNoResultsMessageToBePresent( element ); + } ); + + it( 'should show only high utility items in the suggested tab', () => { + const element = initializeMenuDefaultStateAndReturnElement(); + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + expect( visibleBlocks ).toHaveLength( 3 ); + expect( visibleBlocks[ 0 ].textContent ).toEqual( 'Text' ); + expect( visibleBlocks[ 1 ].textContent ).toEqual( 'Advanced Text' ); + expect( visibleBlocks[ 2 ].textContent ).toEqual( 'Some Other Block' ); + } ); + + it( 'should limit the number of items shown in the suggested tab', () => { + const element = initializeMenuDefaultStateAndReturnElement( + { maxSuggestedItems: 2 } + ); + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__list-item' + ); + expect( visibleBlocks ).toHaveLength( 2 ); + } ); + + it( 'should show items from the embed category in the embed tab', () => { + const element = initializeAllClosedMenuStateAndReturnElement(); + const embedTab = getTabButtonWithContent( element, 'Embeds' ); + + TestUtils.Simulate.click( embedTab ); + + assertOpenedPanels( element, 1 ); + + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + + expect( visibleBlocks ).toHaveLength( 2 ); + expect( visibleBlocks[ 0 ].textContent ).toBe( 'YouTube' ); + expect( visibleBlocks[ 1 ].textContent ).toBe( 'A Text Embed' ); + + assertNoResultsMessageNotToBePresent( element ); + } ); + + it( 'should show reusable items in the reusable tab', () => { + const element = initializeAllClosedMenuStateAndReturnElement(); + const reusableTab = getTabButtonWithContent( element, 'Reusable' ); + + TestUtils.Simulate.click( reusableTab ); + + assertOpenedPanels( element, 1 ); + + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + + expect( visibleBlocks ).toHaveLength( 1 ); + expect( visibleBlocks[ 0 ].textContent ).toBe( 'My reusable block' ); + + assertNoResultsMessageNotToBePresent( element ); + } ); + + it( 'should show the common category blocks', () => { + const element = initializeAllClosedMenuStateAndReturnElement(); + const commonBlocksTab = getTabButtonWithContent( element, 'Common Blocks' ); + + TestUtils.Simulate.click( commonBlocksTab ); + + assertOpenedPanels( element, 1 ); + + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + + expect( visibleBlocks ).toHaveLength( 3 ); + expect( visibleBlocks[ 0 ].textContent ).toBe( 'Text' ); + expect( visibleBlocks[ 1 ].textContent ).toBe( 'Advanced Text' ); + expect( visibleBlocks[ 2 ].textContent ).toBe( 'Some Other Block' ); + + assertNoResultsMessageNotToBePresent( element ); + } ); + + it( 'should disable items with `isDisabled`', () => { + const element = initializeMenuDefaultStateAndReturnElement(); + const layoutTab = getTabButtonWithContent( element, 'Layout Elements' ); + + TestUtils.Simulate.click( layoutTab ); + + const disabledBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item[disabled]' + ); + + expect( disabledBlocks ).toHaveLength( 1 ); + expect( disabledBlocks[ 0 ].textContent ).toBe( 'More' ); + } ); + + it( 'should allow searching for items', () => { + const element = initializeMenuDefaultStateAndReturnElement(); + performSearchWithText( element, 'text' ); + + assertOpenedPanels( element, 2 ); + + const matchingCategories = element.querySelectorAll( + '.components-panel__body-toggle' + ); + + expect( matchingCategories ).toHaveLength( 2 ); + expect( matchingCategories[ 0 ].textContent ).toBe( 'Common Blocks' ); + expect( matchingCategories[ 1 ].textContent ).toBe( 'Embeds' ); + + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + + expect( visibleBlocks ).toHaveLength( 3 ); + expect( visibleBlocks[ 0 ].textContent ).toBe( 'Text' ); + expect( visibleBlocks[ 1 ].textContent ).toBe( 'Advanced Text' ); + expect( visibleBlocks[ 2 ].textContent ).toBe( 'A Text Embed' ); + + assertNoResultsMessageNotToBePresent( element ); + } ); + + it( 'should trim whitespace of search terms', () => { + const element = initializeMenuDefaultStateAndReturnElement(); + performSearchWithText( element, ' text' ); + + assertOpenedPanels( element, 2 ); + + const matchingCategories = element.querySelectorAll( + '.components-panel__body-toggle' + ); + + expect( matchingCategories ).toHaveLength( 2 ); + expect( matchingCategories[ 0 ].textContent ).toBe( 'Common Blocks' ); + expect( matchingCategories[ 1 ].textContent ).toBe( 'Embeds' ); + + const visibleBlocks = element.querySelectorAll( + '.block-editor-block-types-list__item-title' + ); + + expect( visibleBlocks ).toHaveLength( 3 ); + expect( visibleBlocks[ 0 ].textContent ).toBe( 'Text' ); + expect( visibleBlocks[ 1 ].textContent ).toBe( 'Advanced Text' ); + expect( visibleBlocks[ 2 ].textContent ).toBe( 'A Text Embed' ); + + assertNoResultsMessageNotToBePresent( element ); + } ); +} ); + +describe( 'searchItems', () => { + it( 'should search items using the title ignoring case', () => { + expect( searchItems( items, 'TEXT' ) ).toEqual( + [ textItem, advancedTextItem, textEmbedItem ] + ); + } ); + + it( 'should search items using the keywords', () => { + expect( searchItems( items, 'GOOGL' ) ).toEqual( + [ youtubeItem ] + ); + } ); + + it( 'should search items using the categories', () => { + expect( searchItems( items, 'LAYOUT' ) ).toEqual( + [ moreItem ] + ); + } ); + + it( 'should ignore a leading slash on a search term', () => { + expect( searchItems( items, '/GOOGL' ) ).toEqual( + [ youtubeItem ] + ); + } ); +} ); + +describe( 'normalizeTerm', () => { + it( 'should remove diacritics', () => { + expect( normalizeTerm( 'média' ) ).toEqual( + 'media' + ); + } ); + + it( 'should trim whitespace', () => { + expect( normalizeTerm( ' média ' ) ).toEqual( + 'media' + ); + } ); + + it( 'should convert to lowercase', () => { + expect( normalizeTerm( ' Média ' ) ).toEqual( + 'media' + ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/inspector-advanced-controls/index.js b/gb-src/packages/block-editor/src/components/inspector-advanced-controls/index.js new file mode 100644 index 0000000000000..663e95bb491e5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inspector-advanced-controls/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ifBlockEditSelected } from '../block-edit/context'; + +const { Fill, Slot } = createSlotFill( 'InspectorAdvancedControls' ); + +const InspectorAdvancedControls = ifBlockEditSelected( Fill ); + +InspectorAdvancedControls.Slot = Slot; + +export default InspectorAdvancedControls; diff --git a/gb-src/packages/block-editor/src/components/inspector-controls/README.md b/gb-src/packages/block-editor/src/components/inspector-controls/README.md new file mode 100644 index 0000000000000..2c6a21d59dbaf --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inspector-controls/README.md @@ -0,0 +1,395 @@ +# Inspector Controls + +<img src="https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/assets/inspector.png" with="281" height="527" alt="inspector"> + +Inspector Controls appear in the post settings sidebar when a block is being edited. The controls appear in both HTML and visual editing modes, and thus should contain settings that affect the entire block. + +## Usage + +{% codetabs %} +{% ES5 %} +```js +var el = wp.element.createElement, + Fragment = wp.element.Fragment, + registerBlockType = wp.blocks.registerBlockType, + RichText = wp.editor.RichText, + InspectorControls = wp.editor.InspectorControls, + CheckboxControl = wp.components.CheckboxControl, + RadioControl = wp.components.RadioControl, + TextControl = wp.components.TextControl, + ToggleControl = wp.components.ToggleControl, + SelectControl = wp.components.SelectControl; + +registerBlockType( 'my-plugin/inspector-controls-example', { + title: 'Inspector controls example', + + icon: 'universal-access-alt', + + category: 'layout', + + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p', + }, + checkboxField: { + type: 'boolean', + default: true, + }, + radioField: { + type: 'string', + default: 'yes', + }, + textField: { + type: 'string', + }, + toggleField: { + type: 'boolean', + }, + selectField: { + type: 'string', + }, + }, + + edit: function( props ) { + var content = props.attributes.content, + checkboxField = props.attributes.checkboxField, + radioField = props.attributes.radioField, + textField = props.attributes.textField, + toggleField = props.attributes.toggleField, + selectField = props.attributes.selectField; + + function onChangeContent( newContent ) { + props.setAttributes( { content: newContent } ); + } + + function onChangeCheckboxField( newValue ) { + props.setAttributes( { checkboxField: newValue } ); + } + + function onChangeRadioField( newValue ) { + props.setAttributes( { radioField: newValue } ); + } + + function onChangeTextField( newValue ) { + props.setAttributes( { textField: newValue } ); + } + + function onChangeToggleField( newValue ) { + props.setAttributes( { toggleField: newValue } ); + } + + function onChangeSelectField( newValue ) { + props.setAttributes( { selectField: newValue } ); + } + + return ( + el( + Fragment, + null, + el( + InspectorControls, + null, + el( + CheckboxControl, + { + heading: 'Checkbox Field', + label: 'Tick Me', + help: 'Additional help text', + checked: checkboxField, + onChange: onChangeCheckboxField + } + ), + el( + RadioControl, + { + label: 'Radio Field', + selected: radioField, + options: [ + { + label: 'Yes', + value: 'yes' + }, + { + label: 'No', + value: 'no' + } + ], + onChange: onChangeRadioField + } + ), + el( + TextControl, + { + label: 'Text Field', + help: 'Additional help text', + value: textField, + onChange: onChangeTextField + } + ), + el( + ToggleControl, + { + label: 'Toggle Field', + checked: toggleField, + onChange: onChangeToggleField + } + ), + el( + SelectControl, + { + label: 'Select Field', + value: selectField, + options: [ + { + value: 'a', + label: 'Option A' + }, + { + value: 'b', + label: 'Option B' + }, + { + value: 'c', + label: 'Option C' + } + ], + onChange: onChangeSelectField + } + ) + ), + el( + RichText, + { + key: 'editable', + tagName: 'p', + onChange: onChangeContent, + value: content + } + ) + ) + ); + }, + + save: function( props ) { + var content = props.attributes.content, + checkboxField = props.attributes.checkboxField, + radioField = props.attributes.radioField, + textField = props.attributes.textField, + toggleField = props.attributes.toggleField, + selectField = props.attributes.selectField; + + return el( + 'div', + null, + el( + RichText.Content, + { + value: content, + tagName: 'p' + } + ), + el( + 'h2', + null, + 'Inspector Control Fields' + ), + el( + 'ul', + null, + el( + 'li', + null, + 'Checkbox Field: ', + checkboxField + ), + el( + 'li', + null, + 'Radio Field: ', + radioField + ), + el( + 'li', + null, + 'Text Field: ', + textField + ), + el( + 'li', + null, + 'Toggle Field: ', + toggleField + ), + el( + 'li', + null, + 'Select Field: ', + selectField + ) + ) + ); + }, +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { + CheckboxControl, + RadioControl, + TextControl, + ToggleControl, + SelectControl, +} = wp.components; +const { + RichText, + InspectorControls, +} = wp.editor; + +registerBlockType( 'my-plugin/inspector-controls-example', { + title: 'Inspector controls example', + + icon: 'universal-access-alt', + + category: 'layout', + + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p', + }, + checkboxField: { + type: 'boolean', + default: true, + }, + radioField: { + type: 'string', + default: 'yes', + }, + textField: { + type: 'string', + }, + toggleField: { + type: 'boolean', + }, + selectField: { + type: 'string', + }, + }, + + edit( { attributes, setAttributes } ) { + const { content, checkboxField, radioField, textField, toggleField, selectField } = attributes; + + function onChangeContent( newContent ) { + setAttributes( { content: newContent } ); + } + + function onChangeCheckboxField( newValue ) { + setAttributes( { checkboxField: newValue } ); + } + + function onChangeRadioField( newValue ) { + setAttributes( { radioField: newValue } ); + } + + function onChangeTextField( newValue ) { + setAttributes( { textField: newValue } ); + } + + function onChangeToggleField( newValue ) { + setAttributes( { toggleField: newValue } ); + } + + function onChangeSelectField( newValue ) { + setAttributes( { selectField: newValue } ); + } + + return ( + <> + <InspectorControls> + + <CheckboxControl + heading="Checkbox Field" + label="Tick Me" + help="Additional help text" + checked={ checkboxField } + onChange={ onChangeCheckboxField } + /> + + <RadioControl + label="Radio Field" + selected={ radioField } + options={ + [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ] + } + onChange={ onChangeRadioField } + /> + + <TextControl + label="Text Field" + help="Additional help text" + value={ textField } + onChange={ onChangeTextField } + /> + + <ToggleControl + label="Toggle Field" + checked={ toggleField } + onChange={ onChangeToggleField } + /> + + <SelectControl + label="Select Control" + value={ selectField } + options={ + [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + { value: 'c', label: 'Option C' }, + ] + } + onChange={ onChangeSelectField } + /> + + </InspectorControls> + + <RichText + key="editable" + tagName="p" + onChange={ onChangeContent } + value={ content } + /> + </> + ); + }, + + save( { attributes } ) { + const { content, checkboxField, radioField, textField, toggleField, selectField } = attributes; + + return ( + <div> + <RichText.Content + value={ content } + tagName="p" + /> + + <h2>Inspector Control Fields</h2> + <ul> + <li>Checkbox Field: { checkboxField }</li> + <li>Radio Field: { radioField }</li> + <li>Text Field: { textField }</li> + <li>Toggle Field: { toggleField }</li> + <li>Select Field: { selectField }</li> + </ul> + </div> + ); + }, +} ); +``` +{% end %} diff --git a/gb-src/packages/block-editor/src/components/inspector-controls/index.js b/gb-src/packages/block-editor/src/components/inspector-controls/index.js new file mode 100644 index 0000000000000..b97df2f8d245e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/inspector-controls/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ifBlockEditSelected } from '../block-edit/context'; + +const { Fill, Slot } = createSlotFill( 'InspectorControls' ); + +const InspectorControls = ifBlockEditSelected( Fill ); + +InspectorControls.Slot = Slot; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inspector-controls/README.md + */ +export default InspectorControls; diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/README.md b/gb-src/packages/block-editor/src/components/media-placeholder/README.md new file mode 100644 index 0000000000000..b35b00b7d7a53 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/README.md @@ -0,0 +1,166 @@ +MediaPlaceholder +=========== + +`MediaPlaceholder` is a React component used to render either the media associated with a block, or an editing interface to replace the media for a block. + +## Usage + +An example usage which sets the URL of the selected image to `theImage` attributes. + +``` +const { MediaPlaceholder } = wp.editor; + +... + + edit: ( { attributes, setAttributes } ) { + const mediaPlaceholder = <MediaPlaceholder + onSelect = { + ( el ) => { + setAttributes( { theImage: el.url } ); + } + } + allowedTypes = { [ 'image' ] } + multiple = { false } + labels = { { title: 'The Image' } } + > + "extra content" + </MediaPlaceholder>; + ... + } +``` + +## Props + +### accept + +A string passed to `FormFileUpload` that tells the browser which file types can be upload to the upload window the browser use e.g: `image/*,video/*`. +More information about this string is available in https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers. +This property is similar to the `allowedTypes` property. The difference is the format and the fact that this property affects the behavior of `FormFileUpload` while `allowedTypes` affects the behavior `MediaUpload`. + +- Type: `String` +- Required: No + +### addToGallery + +If true, and if `gallery === true` the gallery media modal opens directly in the media library where the user can add additional images. When uploading/selecting files on the placeholder, the placeholder appends the files to the existing files list. +If false the gallery media modal opens in the edit mode where the user can edit existing images, by reordering them, remove them, or change their attributes. When uploading/selecting files on the placeholder the files replace the existing files list. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### allowedTypes + +Array with the types of the media to upload/select from the media library. +Each type is a string that can contain the general mime type e.g: `image`, `audio`, `text`, +or the complete mime type e.g: `audio/mpeg`, `image/gif`. +If allowedTypes is unset all mime types should be allowed. +This property is similar to the `accept` property. The difference is the format and the fact that this property affects the behavior of `MediaUpload` while `accept` affects the behavior `FormFileUpload`. + +- Type: `Array` +- Required: No + +### className + +Class name added to the placeholder. + +- Type: `String` +- Required: No + +### disableDropZone + +If true, the Drop Zone will not be rendered. Users won't be able to drag & drop any media into the component or the block. The UI controls to upload the media via file, url or the media library would be intact. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### dropZoneUIOnly + +If true, only the Drop Zone will be rendered. No UI controls to upload the media will be shown. +The `disableDropZone` prop still takes precedence over `dropZoneUIOnly` – specifying both as true will result in nothing to be rendered. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### icon + +Icon to display left of the title. When passed as a `String`, the icon will be resolved as a [Dashicon](https://developer.wordpress.org/resource/dashicons/). Alternatively, you can pass in a `WPComponent` such as `BlockIcon`to render instead. + +- Type: `String|WPComponent` +- Required: No + +### isAppender + +If true, the property changes the look of the placeholder to be adequate to scenarios where new files are added to an already existing set of files, e.g., adding files to a gallery. +If false the default placeholder style is used. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### labels + +An object that can contain a `title` and `instructions` properties. These properties are passed to the placeholder component as `label` and `instructions` respectively. + +- Type: `Object` +- Required: No + + +### multiple + +Whether to allow multiple selection of files or not. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### onError + +Callback called when an upload error happens. + +- Type: `Function` +- Required: No + +### onSelect + +Callback called when the files are selected/uploaded. +The call back receives an array with the new files. Each element of the collection is an object containing the media properties of the file e.g.: `url`, `id`,... + +- Type: `Function` +- Required: Yes + +### value + +Media ID (or media IDs if multiple is true) to be selected by default when opening the media library. + +- Type: `Number|Array` +- Required: No + + +## Extend + +It includes a `wp.hooks` filter `editor.MediaPlaceholder` that enables developers to replace or extend it. + +_Example:_ + +Replace implementation of the placeholder: + +```js +function replaceMediaPlaceholder() { + return function() { + return wp.element.createElement( + 'div', + {}, + 'The replacement contents or components.' + ); + } +} + +wp.hooks.addFilter( + 'editor.MediaPlaceholder', + 'my-plugin/replace-media-placeholder', + replaceMediaPlaceholder +); +``` diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/index.js b/gb-src/packages/block-editor/src/components/media-placeholder/index.js new file mode 100644 index 0000000000000..df92ab71ee6ec --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/index.js @@ -0,0 +1,434 @@ +/** + * External dependencies + */ +import { + every, + get, + isArray, + noop, + startsWith, +} from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + Button, + FormFileUpload, + Placeholder, + DropZone, + IconButton, + withFilters, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import MediaUpload from '../media-upload'; +import MediaUploadCheck from '../media-upload/check'; +import URLPopover from '../url-popover'; + +const InsertFromURLPopover = ( { src, onChange, onSubmit, onClose } ) => ( + <URLPopover onClose={ onClose }> + <form + className="editor-media-placeholder__url-input-form block-editor-media-placeholder__url-input-form" + onSubmit={ onSubmit } + > + <input + className="editor-media-placeholder__url-input-field block-editor-media-placeholder__url-input-field" + type="url" + aria-label={ __( 'URL' ) } + placeholder={ __( 'Paste or type URL' ) } + onChange={ onChange } + value={ src } + /> + <IconButton + className="editor-media-placeholder__url-input-submit-button block-editor-media-placeholder__url-input-submit-button" + icon="editor-break" + label={ __( 'Apply' ) } + type="submit" + /> + </form> + </URLPopover> +); + +export class MediaPlaceholder extends Component { + constructor() { + super( ...arguments ); + this.state = { + src: '', + isURLInputVisible: false, + }; + this.onChangeSrc = this.onChangeSrc.bind( this ); + this.onSubmitSrc = this.onSubmitSrc.bind( this ); + this.onUpload = this.onUpload.bind( this ); + this.onFilesUpload = this.onFilesUpload.bind( this ); + this.openURLInput = this.openURLInput.bind( this ); + this.closeURLInput = this.closeURLInput.bind( this ); + } + + onlyAllowsImages() { + const { allowedTypes } = this.props; + if ( ! allowedTypes ) { + return false; + } + return every( allowedTypes, ( allowedType ) => { + return allowedType === 'image' || startsWith( allowedType, 'image/' ); + } ); + } + + componentDidMount() { + this.setState( { src: get( this.props.value, [ 'src' ], '' ) } ); + } + + componentDidUpdate( prevProps ) { + if ( get( prevProps.value, [ 'src' ], '' ) !== get( this.props.value, [ 'src' ], '' ) ) { + this.setState( { src: get( this.props.value, [ 'src' ], '' ) } ); + } + } + + onChangeSrc( event ) { + this.setState( { src: event.target.value } ); + } + + onSubmitSrc( event ) { + event.preventDefault(); + if ( this.state.src && this.props.onSelectURL ) { + this.props.onSelectURL( this.state.src ); + this.closeURLInput(); + } + } + + onUpload( event ) { + this.onFilesUpload( event.target.files ); + } + + onFilesUpload( files ) { + const { + addToGallery, + allowedTypes, + mediaUpload, + multiple, + onError, + onSelect, + value = [], + } = this.props; + let setMedia; + if ( multiple ) { + if ( addToGallery ) { + const currentValue = value; + setMedia = ( newMedia ) => { + onSelect( currentValue.concat( newMedia ) ); + }; + } else { + setMedia = onSelect; + } + } else { + setMedia = ( [ media ] ) => onSelect( media ); + } + mediaUpload( { + allowedTypes, + filesList: files, + onFileChange: setMedia, + onError, + } ); + } + + openURLInput() { + this.setState( { isURLInputVisible: true } ); + } + + closeURLInput() { + this.setState( { isURLInputVisible: false } ); + } + + renderPlaceholder( content, onClick ) { + const { + allowedTypes = [], + className, + icon, + isAppender, + labels = {}, + onDoubleClick, + mediaPreview, + notices, + onSelectURL, + mediaUpload, + children, + } = this.props; + + let instructions = labels.instructions; + let title = labels.title; + + if ( ! mediaUpload && ! onSelectURL ) { + instructions = __( 'To edit this block, you need permission to upload media.' ); + } + + if ( instructions === undefined || title === undefined ) { + const isOneType = 1 === allowedTypes.length; + const isAudio = isOneType && 'audio' === allowedTypes[ 0 ]; + const isImage = isOneType && 'image' === allowedTypes[ 0 ]; + const isVideo = isOneType && 'video' === allowedTypes[ 0 ]; + + if ( instructions === undefined && mediaUpload ) { + instructions = __( 'Upload a media file or pick one from your media library.' ); + + if ( isAudio ) { + instructions = __( 'Upload an audio file, pick one from your media library, or add one with a URL.' ); + } else if ( isImage ) { + instructions = __( 'Upload an image file, pick one from your media library, or add one with a URL.' ); + } else if ( isVideo ) { + instructions = __( 'Upload a video file, pick one from your media library, or add one with a URL.' ); + } + } + + if ( title === undefined ) { + title = __( 'Media' ); + + if ( isAudio ) { + title = __( 'Audio' ); + } else if ( isImage ) { + title = __( 'Image' ); + } else if ( isVideo ) { + title = __( 'Video' ); + } + } + } + + const placeholderClassName = classnames( + 'block-editor-media-placeholder', + 'editor-media-placeholder', + className, + { 'is-appender': isAppender } + ); + + return ( + <Placeholder + icon={ icon } + label={ title } + instructions={ instructions } + className={ placeholderClassName } + notices={ notices } + onClick={ onClick } + onDoubleClick={ onDoubleClick } + preview={ mediaPreview } + > + { content } + { children } + </Placeholder> + ); + } + + renderDropZone() { + const { disableDropZone, onHTMLDrop = noop } = this.props; + + if ( disableDropZone ) { + return null; + } + + return ( + <DropZone + onFilesDrop={ this.onFilesUpload } + onHTMLDrop={ onHTMLDrop } + /> + ); + } + + renderCancelLink() { + const { + onCancel, + } = this.props; + return ( onCancel && + <Button + className="block-editor-media-placeholder__cancel-button" + title={ __( 'Cancel' ) } + isLink + onClick={ onCancel } + > + { __( 'Cancel' ) } + </Button> + ); + } + + renderUrlSelectionUI() { + const { + onSelectURL, + } = this.props; + if ( ! onSelectURL ) { + return null; + } + const { + isURLInputVisible, + src, + } = this.state; + return ( + <div className="editor-media-placeholder__url-input-container block-editor-media-placeholder__url-input-container"> + <Button + className="editor-media-placeholder__button block-editor-media-placeholder__button" + onClick={ this.openURLInput } + isToggled={ isURLInputVisible } + isLarge + > + { __( 'Insert from URL' ) } + </Button> + { isURLInputVisible && ( + <InsertFromURLPopover + src={ src } + onChange={ this.onChangeSrc } + onSubmit={ this.onSubmitSrc } + onClose={ this.closeURLInput } + /> + ) } + </div> + ); + } + + renderMediaUploadChecked() { + const { + accept, + addToGallery, + allowedTypes = [], + isAppender, + mediaUpload, + multiple = false, + onSelect, + value = {}, + } = this.props; + + const mediaLibraryButton = ( + <MediaUpload + addToGallery={ addToGallery } + gallery={ multiple && this.onlyAllowsImages() } + multiple={ multiple } + onSelect={ onSelect } + allowedTypes={ allowedTypes } + value={ + isArray( value ) ? + value.map( ( { id } ) => id ) : + value.id + } + render={ ( { open } ) => { + return ( + <Button + isLarge + className={ classnames( + 'editor-media-placeholder__button', + 'editor-media-placeholder__media-library-button' + ) } + onClick={ ( event ) => { + event.stopPropagation(); + open(); + } } + > + { __( 'Media Library' ) } + </Button> + ); + } } + /> + ); + + if ( mediaUpload && isAppender ) { + return ( + <> + { this.renderDropZone() } + <FormFileUpload + onChange={ this.onUpload } + accept={ accept } + multiple={ multiple } + render={ ( { openFileDialog } ) => { + const content = ( + <> + <IconButton + isLarge + className={ classnames( + 'block-editor-media-placeholder__button', + 'editor-media-placeholder__button', + 'block-editor-media-placeholder__upload-button' + ) } + icon="upload" + > + { __( 'Upload' ) } + </IconButton> + { mediaLibraryButton } + { this.renderUrlSelectionUI() } + { this.renderCancelLink() } + </> + ); + return this.renderPlaceholder( content, openFileDialog ); + } } + /> + </> + ); + } + + if ( mediaUpload ) { + const content = ( + <> + { this.renderDropZone() } + <FormFileUpload + isLarge + className={ classnames( + 'block-editor-media-placeholder__button', + 'editor-media-placeholder__button', + 'block-editor-media-placeholder__upload-button' + ) } + onChange={ this.onUpload } + accept={ accept } + multiple={ multiple } + > + { __( 'Upload' ) } + </FormFileUpload> + { mediaLibraryButton } + { this.renderUrlSelectionUI() } + { this.renderCancelLink() } + </> + ); + return this.renderPlaceholder( content ); + } + + return this.renderPlaceholder( mediaLibraryButton ); + } + + render() { + const { + dropZoneUIOnly, + } = this.props; + + if ( dropZoneUIOnly ) { + return ( + <MediaUploadCheck> + { this.renderDropZone() } + </MediaUploadCheck> + ); + } + + return ( + <MediaUploadCheck + fallback={ this.renderPlaceholder( this.renderUrlSelectionUI() ) } + > + { this.renderMediaUploadChecked() } + </MediaUploadCheck> + ); + } +} + +const applyWithSelect = withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + + return { + mediaUpload: getSettings().__experimentalMediaUpload, + }; +} ); + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-placeholder/README.md + */ +export default compose( + applyWithSelect, + withFilters( 'editor.MediaPlaceholder' ), +)( MediaPlaceholder ); diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/index.native.js b/gb-src/packages/block-editor/src/components/media-placeholder/index.native.js new file mode 100644 index 0000000000000..a19a28588200c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/index.native.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { View, Text, TouchableWithoutFeedback } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { MediaUpload, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; + +function MediaPlaceholder( props ) { + const { mediaType, labels = {}, icon, onSelectURL } = props; + + const isImage = MEDIA_TYPE_IMAGE === mediaType; + const isVideo = MEDIA_TYPE_VIDEO === mediaType; + + let placeholderTitle = labels.title; + if ( placeholderTitle === undefined ) { + placeholderTitle = __( 'Media' ); + if ( isImage ) { + placeholderTitle = __( 'Image' ); + } else if ( isVideo ) { + placeholderTitle = __( 'Video' ); + } + } + + let instructions = labels.instructions; + if ( instructions === undefined ) { + if ( isImage ) { + instructions = __( 'ADD IMAGE' ); + } else if ( isVideo ) { + instructions = __( 'ADD VIDEO' ); + } + } + + let accessibilityHint = __( 'Double tap to select' ); + if ( isImage ) { + accessibilityHint = __( 'Double tap to select an image' ); + } else if ( isVideo ) { + accessibilityHint = __( 'Double tap to select a video' ); + } + + return ( + <MediaUpload + mediaType={ mediaType } + onSelectURL={ onSelectURL } + render={ ( { open, getMediaOptions } ) => { + return ( + <TouchableWithoutFeedback + accessibilityLabel={ sprintf( + /* translators: accessibility text for the media block empty state. %s: media type */ + __( '%s block. Empty' ), + placeholderTitle + ) } + accessibilityRole={ 'button' } + accessibilityHint={ accessibilityHint } + onPress={ ( event ) => { + props.onFocus( event ); + open(); + } } + > + <View style={ styles.emptyStateContainer }> + { getMediaOptions() } + <View style={ styles.modalIcon }> + { icon } + </View> + <Text style={ styles.emptyStateTitle }> + { placeholderTitle } + </Text> + <Text style={ styles.emptyStateDescription }> + { instructions } + </Text> + </View> + </TouchableWithoutFeedback> + ); + } } /> + ); +} + +export default MediaPlaceholder; diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/style.scss b/gb-src/packages/block-editor/src/components/media-placeholder/style.scss new file mode 100644 index 0000000000000..794163e6678a8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/style.scss @@ -0,0 +1,79 @@ +.block-editor-media-placeholder__url-input-container { + width: 100%; + + // Reset the margin to ensure the url popover is adjacent to the button. + .block-editor-media-placeholder__button { + margin-bottom: 0; + } +} + +.block-editor-media-placeholder__url-input-form { + display: flex; + + // Selector requires a lot of specificity to override base styles. + input[type="url"].block-editor-media-placeholder__url-input-field { + width: 100%; + @include break-small() { + width: 300px; + } + + flex-grow: 1; + border: none; + border-radius: 0; + margin: 2px; + + } +} + +.block-editor-media-placeholder__url-input-submit-button { + flex-shrink: 1; +} + +.block-editor-media-placeholder__button { + margin-bottom: 0.5rem; + + .dashicon { + vertical-align: middle; + margin-bottom: 3px; + } + + &:hover { + color: $dark-gray-800; + } +} + +.block-editor-media-placeholder__cancel-button.is-link { + margin: 1em; + display: block; +} + +.components-form-file-upload .block-editor-media-placeholder__button { + margin-right: $grid-size-small; +} + +.block-editor-media-placeholder.is-appender { + min-height: 100px; + outline: $border-width dashed $dark-gray-150; + + &:hover { + outline: $border-width dashed $dark-gray-500; + cursor: pointer; + } + + .is-dark-theme & { + + &:hover { + outline: $border-width dashed $white; + } + } + + .block-editor-media-placeholder__upload-button { + margin-right: $grid-size-small; + &.components-button:hover, + &.components-button:focus { + box-shadow: none; + border: $border-width solid $dark-gray-500; + } + } + +} diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/styles.native.scss b/gb-src/packages/block-editor/src/components/media-placeholder/styles.native.scss new file mode 100644 index 0000000000000..f76be4b8f36a4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/styles.native.scss @@ -0,0 +1,35 @@ +.emptyStateContainer { + flex: 1; + height: 142; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: $gray-light; + padding-left: 12; + padding-right: 12; + padding-top: 12; + padding-bottom: 12; +} + +.emptyStateTitle { + text-align: center; + margin-top: 8; + margin-bottom: 10; + font-size: 14; + color: #2e4453; +} + +.emptyStateDescription { + text-align: center; + color: $blue-wordpress; + font-size: 14; + font-weight: 500; +} + +.modalIcon { + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + fill: $gray-dark; +} diff --git a/gb-src/packages/block-editor/src/components/media-placeholder/test/index.js b/gb-src/packages/block-editor/src/components/media-placeholder/test/index.js new file mode 100644 index 0000000000000..6d7050eb84f06 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-placeholder/test/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { MediaPlaceholder } from '../'; + +jest.mock( '../../media-upload/check', () => () => null ); + +describe( 'MediaPlaceholder', () => { + it( 'renders successfully when allowedTypes property is not specified', () => { + expect( () => mount( + <MediaPlaceholder hasUploadPermissions={ false } /> + ) ).not.toThrow(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/media-upload/README.md b/gb-src/packages/block-editor/src/components/media-upload/README.md new file mode 100644 index 0000000000000..56714034ec0ba --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-upload/README.md @@ -0,0 +1,143 @@ +MediaUpload +=========== + +MediaUpload is a React component used to render a button that opens the WordPress media modal. + +## Setup + +This is a placeholder component necessary to make it possible to provide an integration with the core blocks that handle media files. By default it renders nothing but it provides a way to have it overridden with the `components.MediaUpload` filter. + +```jsx +import { addFilter } from '@wordpress/hooks'; +import MediaUpload from './media-upload'; + +const replaceMediaUpload = () => MediaUpload; + +addFilter( + 'editor.MediaUpload', + 'core/edit-post/components/media-upload/replace-media-upload', + replaceMediaUpload +); +``` + +You can check how this component is implemented for the edit post page using `wp.media` module in [edit-post](https://github.com/WordPress/gutenberg/tree/master/packages/edit-post/src/hooks/components/media-upload/index.js). + +## Usage + +To make sure the current user has Upload permissions, you need to wrap the MediaUpload component into the MediaUploadCheck one. + +```jsx +import { Button } from '@wordpress/components'; +import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor'; + +const ALLOWED_MEDIA_TYPES = [ 'audio' ]; + +function MyMediaUploader() { + return ( + <MediaUploadCheck> + <MediaUpload + onSelect={ ( media ) => console.log( 'selected ' + media.length ) } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ mediaId } + render={ ( { open } ) => ( + <Button onClick={ open }> + Open Media Library + </Button> + ) } + /> + </MediaUploadCheck> + ); +} +``` + +## Props + +The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content. + +### allowedTypes + +Array with the types of the media to upload/select from the media library. +Each type is a string that can contain the general mime type e.g: 'image', 'audio', 'text', +or the complete mime type e.g: 'audio/mpeg', 'image/gif'. +If allowedTypes is unset all mime types should be allowed. + +- Type: `Array` +- Required: No + +### multiple + +Whether to allow multiple selections or not. + +- Type: `Boolean` +- Required: No +- Default: false + +### value + +Media ID (or media IDs if multiple is true) to be selected by default when opening the media library. + +- Type: `Number|Array` +- Required: No + +### onClose + +Callback called when the media modal is closed. + +This is called both when media is selected and when the user closes the modal without making a selection. + +- Type: `Function` +- Required: No + +### onSelect + +Callback called when the media modal is closed after media is selected. + +This is called subsequent to `onClose` when media is selected. The selected media are passed as an argument. + +- Type: `Function` +- Required: Yes + +### title + +Title displayed in the media modal. + +- Type: `String` +- Required: No +- Default: `Select or Upload Media` + +### modalClass + +CSS class added to the media modal frame. + +- Type: `String` +- Required: No + + +### addToGallery + +If true, the gallery media modal opens directly in the media library where the user can add additional images. +If false the gallery media modal opens in the edit mode where the user can edit existing images, by reordering them, remove them, or change their attributes. +Only applies if `gallery === true`. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### gallery + +If true, the component will initiate all the states required to represent a gallery. By default, the media modal opens in the gallery edit frame, but that can be changed using the `addToGallery`flag. + +- Type: `Boolean` +- Required: No +- Default: `false` + +## render + +A callback invoked to render the Button opening the media library. + +- Type: `Function` +- Required: Yes + +The first argument of the callback is an object containing the following properties: + + - `open`: A function opening the media modal when called diff --git a/gb-src/packages/block-editor/src/components/media-upload/check.js b/gb-src/packages/block-editor/src/components/media-upload/check.js new file mode 100644 index 0000000000000..6f2903be5b1e1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-upload/check.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +export function MediaUploadCheck( { hasUploadPermissions, fallback = null, children } ) { + return hasUploadPermissions ? children : fallback; +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-upload/README.md + */ +export default withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + + return { + hasUploadPermissions: !! getSettings().__experimentalMediaUpload, + }; +} )( MediaUploadCheck ); diff --git a/gb-src/packages/block-editor/src/components/media-upload/index.js b/gb-src/packages/block-editor/src/components/media-upload/index.js new file mode 100644 index 0000000000000..1cb3e51109727 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-upload/index.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { withFilters } from '@wordpress/components'; + +/** + * This is a placeholder for the media upload component necessary to make it possible to provide + * an integration with the core blocks that handle media files. By default it renders nothing but + * it provides a way to have it overridden with the `editor.MediaUpload` filter. + * + * @return {WPElement} Media upload element. + */ +const MediaUpload = () => null; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/media-upload/README.md + */ +export default withFilters( 'editor.MediaUpload' )( MediaUpload ); diff --git a/gb-src/packages/block-editor/src/components/media-upload/index.native.js b/gb-src/packages/block-editor/src/components/media-upload/index.native.js new file mode 100644 index 0000000000000..14ed5d8dbe623 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-upload/index.native.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import React from 'react'; +import { + requestMediaPickFromMediaLibrary, + requestMediaPickFromDeviceLibrary, + requestMediaPickFromDeviceCamera, +} from 'react-native-gutenberg-bridge'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Picker } from '@wordpress/components'; + +export const MEDIA_TYPE_IMAGE = 'image'; +export const MEDIA_TYPE_VIDEO = 'video'; + +export const MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_CHOOSE_FROM_DEVICE = 'choose_from_device'; +export const MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_TAKE_MEDIA = 'take_media'; +export const MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_WORD_PRESS_LIBRARY = 'wordpress_media_library'; + +export const OPTION_TAKE_VIDEO = __( 'Take a Video' ); +export const OPTION_TAKE_PHOTO = __( 'Take a Photo' ); + +export class MediaUpload extends React.Component { + getTakeMediaLabel() { + const { mediaType } = this.props; + + if ( mediaType === MEDIA_TYPE_IMAGE ) { + return OPTION_TAKE_PHOTO; + } else if ( mediaType === MEDIA_TYPE_VIDEO ) { + return OPTION_TAKE_VIDEO; + } + } + + getMediaOptionsItems() { + return [ + { icon: this.getChooseFromDeviceIcon(), value: MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_CHOOSE_FROM_DEVICE, label: __( 'Choose from device' ) }, + { icon: this.getTakeMediaIcon(), value: MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_TAKE_MEDIA, label: this.getTakeMediaLabel() }, + { icon: this.getWordPressLibraryIcon(), value: MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_WORD_PRESS_LIBRARY, label: __( 'WordPress Media Library' ) }, + ]; + } + + getChooseFromDeviceIcon() { + const { mediaType } = this.props; + + if ( mediaType === MEDIA_TYPE_IMAGE ) { + return 'format-image'; + } else if ( mediaType === MEDIA_TYPE_VIDEO ) { + return 'format-video'; + } + } + + getTakeMediaIcon() { + return 'camera'; + } + + getWordPressLibraryIcon() { + return 'wordpress-alt'; + } + + render() { + const { mediaType } = this.props; + + const onMediaLibraryButtonPressed = () => { + requestMediaPickFromMediaLibrary( [ mediaType ], ( mediaId, mediaUrl ) => { + if ( mediaId ) { + this.props.onSelectURL( mediaId, mediaUrl ); + } + } ); + }; + + const onMediaUploadButtonPressed = () => { + requestMediaPickFromDeviceLibrary( [ mediaType ], ( mediaId, mediaUrl ) => { + if ( mediaId ) { + this.props.onSelectURL( mediaId, mediaUrl ); + } + } ); + }; + + const onMediaCaptureButtonPressed = () => { + requestMediaPickFromDeviceCamera( [ mediaType ], ( mediaId, mediaUrl ) => { + if ( mediaId ) { + this.props.onSelectURL( mediaId, mediaUrl ); + } + } ); + }; + + const mediaOptions = this.getMediaOptionsItems(); + + let picker; + + const onPickerPresent = () => { + picker.presentPicker(); + }; + + const getMediaOptions = () => ( + <Picker + hideCancelButton={ true } + ref={ ( instance ) => picker = instance } + options={ mediaOptions } + onChange={ ( value ) => { + if ( value === MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_CHOOSE_FROM_DEVICE ) { + onMediaUploadButtonPressed(); + } else if ( value === MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_TAKE_MEDIA ) { + onMediaCaptureButtonPressed(); + } else if ( value === MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_WORD_PRESS_LIBRARY ) { + onMediaLibraryButtonPressed(); + } + } } + /> + ); + return this.props.render( { open: onPickerPresent, getMediaOptions } ); + } +} + +export default MediaUpload; diff --git a/gb-src/packages/block-editor/src/components/media-upload/test/index.native.js b/gb-src/packages/block-editor/src/components/media-upload/test/index.native.js new file mode 100644 index 0000000000000..6eca7575ac408 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/media-upload/test/index.native.js @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { TouchableWithoutFeedback } from 'react-native'; +import { + requestMediaPickFromMediaLibrary, + requestMediaPickFromDeviceLibrary, + requestMediaPickFromDeviceCamera, +} from 'react-native-gutenberg-bridge'; + +/** + * Internal dependencies + */ +import { + MediaUpload, + MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_CHOOSE_FROM_DEVICE, + MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_TAKE_MEDIA, + MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_WORD_PRESS_LIBRARY, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, + OPTION_TAKE_VIDEO, + OPTION_TAKE_PHOTO, +} from '../index'; + +const MEDIA_URL = 'http://host.media.type'; +const MEDIA_ID = 123; + +describe( 'MediaUpload component', () => { + it( 'renders without crashing', () => { + const wrapper = shallow( + <MediaUpload render={ () => {} } /> + ); + expect( wrapper ).toBeTruthy(); + } ); + + it( 'opens media options picker', () => { + const wrapper = shallow( + <MediaUpload render={ ( { open, getMediaOptions } ) => { + return ( + <TouchableWithoutFeedback onPress={ open }> + { getMediaOptions() } + </TouchableWithoutFeedback> + ); + } } /> + ); + expect( wrapper.find( 'Picker' ) ).toHaveLength( 1 ); + } ); + + it( 'shows right media capture option for media type', () => { + const expectOptionForMediaType = ( mediaType, expectedOption ) => { + const wrapper = shallow( + <MediaUpload + mediaType={ mediaType } + render={ ( { open, getMediaOptions } ) => { + return ( + <TouchableWithoutFeedback onPress={ open }> + { getMediaOptions() } + </TouchableWithoutFeedback> + ); + } } /> + ); + expect( wrapper.find( 'Picker' ).props().options.filter( ( item ) => item.label === expectedOption ) ).toHaveLength( 1 ); + }; + expectOptionForMediaType( MEDIA_TYPE_IMAGE, OPTION_TAKE_PHOTO ); + expectOptionForMediaType( MEDIA_TYPE_VIDEO, OPTION_TAKE_VIDEO ); + } ); + + const expectMediaPickerForOption = ( option, requestFunction ) => { + requestFunction.mockImplementation( ( mediaTypes, callback ) => { + expect( mediaTypes[ 0 ] ).toEqual( MEDIA_TYPE_VIDEO ); + callback( MEDIA_ID, MEDIA_URL ); + } ); + + const onSelectURL = jest.fn(); + + const wrapper = shallow( + <MediaUpload + mediaType={ MEDIA_TYPE_VIDEO } + onSelectURL={ onSelectURL } + render={ ( { open, getMediaOptions } ) => { + return ( + <TouchableWithoutFeedback onPress={ open }> + { getMediaOptions() } + </TouchableWithoutFeedback> + ); + } } /> + ); + wrapper.find( 'Picker' ).simulate( 'change', option ); + expect( requestFunction ).toHaveBeenCalledTimes( 1 ); + + expect( onSelectURL ).toHaveBeenCalledTimes( 1 ); + expect( onSelectURL ).toHaveBeenCalledWith( MEDIA_ID, MEDIA_URL ); + }; + + it( 'can select media from device library', () => { + expectMediaPickerForOption( MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_CHOOSE_FROM_DEVICE, requestMediaPickFromDeviceLibrary ); + } ); + + it( 'can select media from WP media library', () => { + expectMediaPickerForOption( MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_WORD_PRESS_LIBRARY, requestMediaPickFromMediaLibrary ); + } ); + + it( 'can select media by capturig', () => { + expectMediaPickerForOption( MEDIA_UPLOAD_BOTTOM_SHEET_VALUE_TAKE_MEDIA, requestMediaPickFromDeviceCamera ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/multi-select-scroll-into-view/index.js b/gb-src/packages/block-editor/src/components/multi-select-scroll-into-view/index.js new file mode 100644 index 0000000000000..240a6dfaadc3e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/multi-select-scroll-into-view/index.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import scrollIntoView from 'dom-scroll-into-view'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { getScrollContainer } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import { getBlockDOMNode } from '../../utils/dom'; + +class MultiSelectScrollIntoView extends Component { + componentDidUpdate() { + // Relies on expectation that `componentDidUpdate` will only be called + // if value of `extentClientId` changes. + this.scrollIntoView(); + } + + /** + * Ensures that if a multi-selection exists, the extent of the selection is + * visible within the nearest scrollable container. + */ + scrollIntoView() { + const { extentClientId } = this.props; + if ( ! extentClientId ) { + return; + } + + const extentNode = getBlockDOMNode( extentClientId ); + if ( ! extentNode ) { + return; + } + + const scrollContainer = getScrollContainer( extentNode ); + + // If there's no scroll container, it follows that there's no scrollbar + // and thus there's no need to try to scroll into view. + if ( ! scrollContainer ) { + return; + } + + scrollIntoView( extentNode, scrollContainer, { + onlyScrollIfNeeded: true, + } ); + } + + render() { + return null; + } +} + +export default withSelect( ( select ) => { + const { getLastMultiSelectedBlockClientId } = select( 'core/block-editor' ); + + return { + extentClientId: getLastMultiSelectedBlockClientId(), + }; +} )( MultiSelectScrollIntoView ); diff --git a/gb-src/packages/block-editor/src/components/multi-selection-inspector/index.js b/gb-src/packages/block-editor/src/components/multi-selection-inspector/index.js new file mode 100644 index 0000000000000..bc0bcef955883 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/multi-selection-inspector/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { sprintf, _n } from '@wordpress/i18n'; +import { withSelect } from '@wordpress/data'; +import { serialize } from '@wordpress/blocks'; +import { count as wordCount } from '@wordpress/wordcount'; +import { + Path, + SVG, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function MultiSelectionInspector( { blocks } ) { + const words = wordCount( serialize( blocks ), 'words' ); + + return ( + <div className="editor-multi-selection-inspector__card block-editor-multi-selection-inspector__card"> + <BlockIcon icon={ + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" /></SVG> + } showColors /> + <div className="editor-multi-selection-inspector__card-content block-editor-multi-selection-inspector__card-content"> + <div className="editor-multi-selection-inspector__card-title block-editor-multi-selection-inspector__card-title"> + { + /* translators: %d: number of blocks */ + sprintf( _n( '%d block', '%d blocks', blocks.length ), blocks.length ) + } + </div> + <div className="editor-multi-selection-inspector__card-description block-editor-multi-selection-inspector__card-description"> + { + /* translators: %d: number of words */ + sprintf( _n( '%d word', '%d words', words ), words ) + } + </div> + </div> + </div> + ); +} + +export default withSelect( ( select ) => { + const { getMultiSelectedBlocks } = select( 'core/block-editor' ); + return { + blocks: getMultiSelectedBlocks(), + }; +} )( MultiSelectionInspector ); diff --git a/gb-src/packages/block-editor/src/components/multi-selection-inspector/style.scss b/gb-src/packages/block-editor/src/components/multi-selection-inspector/style.scss new file mode 100644 index 0000000000000..bd81710ebfcae --- /dev/null +++ b/gb-src/packages/block-editor/src/components/multi-selection-inspector/style.scss @@ -0,0 +1,27 @@ +.block-editor-multi-selection-inspector__card { + display: flex; + align-items: flex-start; + margin: -16px; + padding: 16px; +} + +.block-editor-multi-selection-inspector__card-content { + flex-grow: 1; +} + +.block-editor-multi-selection-inspector__card-title { + font-weight: 500; + margin-bottom: 5px; +} + +.block-editor-multi-selection-inspector__card-description { + font-size: $default-font-size; +} + +.block-editor-multi-selection-inspector__card .block-editor-block-icon { + margin-left: -2px; + margin-right: 10px; + padding: 0 3px; + width: $icon-button-size; + height: $icon-button-size-small; +} diff --git a/gb-src/packages/block-editor/src/components/navigable-toolbar/index.js b/gb-src/packages/block-editor/src/components/navigable-toolbar/index.js new file mode 100644 index 0000000000000..26984e217f3d0 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/navigable-toolbar/index.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { NavigableMenu, KeyboardShortcuts } from '@wordpress/components'; +import { Component, createRef } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; + +class NavigableToolbar extends Component { + constructor() { + super( ...arguments ); + + this.focusToolbar = this.focusToolbar.bind( this ); + + this.toolbar = createRef(); + } + + focusToolbar() { + const tabbables = focus.tabbable.find( this.toolbar.current ); + if ( tabbables.length ) { + tabbables[ 0 ].focus(); + } + } + + componentDidMount() { + if ( this.props.focusOnMount ) { + this.focusToolbar(); + } + + // We use DOM event listeners instead of React event listeners + // because we want to catch events from the underlying DOM tree + // The React Tree can be different from the DOM tree when using + // portals. Block Toolbars for instance are rendered in a separate + // React Tree. + this.toolbar.current.addEventListener( 'keydown', this.switchOnKeyDown ); + } + + componentwillUnmount() { + this.toolbar.current.removeEventListener( 'keydown', this.switchOnKeyDown ); + } + + render() { + const { children, ...props } = this.props; + return ( + <NavigableMenu + orientation="horizontal" + role="toolbar" + ref={ this.toolbar } + { ...omit( props, [ + 'focusOnMount', + ] ) } + > + <KeyboardShortcuts + bindGlobal + // Use the same event that TinyMCE uses in the Classic block for its own `alt+f10` shortcut. + eventName="keydown" + shortcuts={ { + 'alt+f10': this.focusToolbar, + } } + /> + { children } + </NavigableMenu> + ); + } +} + +export default NavigableToolbar; diff --git a/gb-src/packages/block-editor/src/components/observe-typing/README.md b/gb-src/packages/block-editor/src/components/observe-typing/README.md new file mode 100644 index 0000000000000..06f5e85bfdc87 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/observe-typing/README.md @@ -0,0 +1,18 @@ +Observe Typing +============== + +`<ObserveTyping />` is a component used in managing the editor's internal typing flag. When used to wrap content — typically the top-level block list — it observes keyboard and mouse events to set and unset the typing flag. The typing flag is used in considering whether the block border and controls should be visible. While typing, these elements are hidden for a distraction-free experience. + +## Usage + +Wrap the component where blocks are to be rendered with `<ObserveTyping />`: + +```jsx +function VisualEditor() { + return ( + <ObserveTyping> + <BlockList /> + </ObserveTyping> + ); +} +``` diff --git a/gb-src/packages/block-editor/src/components/observe-typing/index.js b/gb-src/packages/block-editor/src/components/observe-typing/index.js new file mode 100644 index 0000000000000..c3ad69b3d834f --- /dev/null +++ b/gb-src/packages/block-editor/src/components/observe-typing/index.js @@ -0,0 +1,222 @@ +/** + * External dependencies + */ +import { over, includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { isTextField } from '@wordpress/dom'; +import { + UP, + RIGHT, + DOWN, + LEFT, + ENTER, + BACKSPACE, + ESCAPE, +} from '@wordpress/keycodes'; +import { withSafeTimeout, compose } from '@wordpress/compose'; + +/** + * Set of key codes upon which typing is to be initiated on a keydown event. + * + * @type {number[]} + */ +const KEY_DOWN_ELIGIBLE_KEY_CODES = [ UP, RIGHT, DOWN, LEFT, ENTER, BACKSPACE ]; + +/** + * Returns true if a given keydown event can be inferred as intent to start + * typing, or false otherwise. A keydown is considered eligible if it is a + * text navigation without shift active. + * + * @param {KeyboardEvent} event Keydown event to test. + * + * @return {boolean} Whether event is eligible to start typing. + */ +function isKeyDownEligibleForStartTyping( event ) { + const { keyCode, shiftKey } = event; + return ! shiftKey && includes( KEY_DOWN_ELIGIBLE_KEY_CODES, keyCode ); +} + +class ObserveTyping extends Component { + constructor() { + super( ...arguments ); + + this.stopTypingOnSelectionUncollapse = this.stopTypingOnSelectionUncollapse.bind( this ); + this.stopTypingOnMouseMove = this.stopTypingOnMouseMove.bind( this ); + this.startTypingInTextField = this.startTypingInTextField.bind( this ); + this.stopTypingOnNonTextField = this.stopTypingOnNonTextField.bind( this ); + this.stopTypingOnEscapeKey = this.stopTypingOnEscapeKey.bind( this ); + + this.onKeyDown = over( [ + this.startTypingInTextField, + this.stopTypingOnEscapeKey, + ] ); + + this.lastMouseMove = null; + } + + componentDidMount() { + this.toggleEventBindings( this.props.isTyping ); + } + + componentDidUpdate( prevProps ) { + if ( this.props.isTyping !== prevProps.isTyping ) { + this.toggleEventBindings( this.props.isTyping ); + } + } + + componentWillUnmount() { + this.toggleEventBindings( false ); + } + + /** + * Bind or unbind events to the document when typing has started or stopped + * respectively, or when component has become unmounted. + * + * @param {boolean} isBound Whether event bindings should be applied. + */ + toggleEventBindings( isBound ) { + const bindFn = isBound ? 'addEventListener' : 'removeEventListener'; + document[ bindFn ]( 'selectionchange', this.stopTypingOnSelectionUncollapse ); + document[ bindFn ]( 'mousemove', this.stopTypingOnMouseMove ); + } + + /** + * On mouse move, unset typing flag if user has moved cursor. + * + * @param {MouseEvent} event Mousemove event. + */ + stopTypingOnMouseMove( event ) { + const { clientX, clientY } = event; + + // We need to check that the mouse really moved because Safari triggers + // mousemove events when shift or ctrl are pressed. + if ( this.lastMouseMove ) { + const { + clientX: lastClientX, + clientY: lastClientY, + } = this.lastMouseMove; + + if ( lastClientX !== clientX || lastClientY !== clientY ) { + this.props.onStopTyping(); + } + } + + this.lastMouseMove = { clientX, clientY }; + } + + /** + * On selection change, unset typing flag if user has made an uncollapsed + * (shift) selection. + */ + stopTypingOnSelectionUncollapse() { + const selection = window.getSelection(); + const isCollapsed = selection.rangeCount > 0 && selection.getRangeAt( 0 ).collapsed; + + if ( ! isCollapsed ) { + this.props.onStopTyping(); + } + } + + /** + * Unsets typing flag if user presses Escape while typing flag is active. + * + * @param {KeyboardEvent} event Keypress or keydown event to interpret. + */ + stopTypingOnEscapeKey( event ) { + if ( this.props.isTyping && event.keyCode === ESCAPE ) { + this.props.onStopTyping(); + } + } + + /** + * Handles a keypress or keydown event to infer intention to start typing. + * + * @param {KeyboardEvent} event Keypress or keydown event to interpret. + */ + startTypingInTextField( event ) { + const { isTyping, onStartTyping } = this.props; + const { type, target } = event; + + // Abort early if already typing, or key press is incurred outside a + // text field (e.g. arrow-ing through toolbar buttons). + // Ignore typing in a block toolbar + if ( isTyping || ! isTextField( target ) || target.closest( '.block-editor-block-toolbar' ) ) { + return; + } + + // Special-case keydown because certain keys do not emit a keypress + // event. Conversely avoid keydown as the canonical event since there + // are many keydown which are explicitly not targeted for typing. + if ( type === 'keydown' && ! isKeyDownEligibleForStartTyping( event ) ) { + return; + } + + onStartTyping(); + } + + /** + * Stops typing when focus transitions to a non-text field element. + * + * @param {FocusEvent} event Focus event. + */ + stopTypingOnNonTextField( event ) { + event.persist(); + + // Since focus to a non-text field via arrow key will trigger before + // the keydown event, wait until after current stack before evaluating + // whether typing is to be stopped. Otherwise, typing will re-start. + this.props.setTimeout( () => { + const { isTyping, onStopTyping } = this.props; + const { target } = event; + if ( isTyping && ! isTextField( target ) ) { + onStopTyping(); + } + } ); + } + + render() { + const { children } = this.props; + + // Disable reason: This component is responsible for capturing bubbled + // keyboard events which are interpreted as typing intent. + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + onFocus={ this.stopTypingOnNonTextField } + onKeyPress={ this.startTypingInTextField } + onKeyDown={ this.onKeyDown } + > + { children } + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/observe-typing/README.md + */ +export default compose( [ + withSelect( ( select ) => { + const { isTyping } = select( 'core/block-editor' ); + + return { + isTyping: isTyping(), + }; + } ), + withDispatch( ( dispatch ) => { + const { startTyping, stopTyping } = dispatch( 'core/block-editor' ); + + return { + onStartTyping: startTyping, + onStopTyping: stopTyping, + }; + } ), + withSafeTimeout, +] )( ObserveTyping ); diff --git a/gb-src/packages/block-editor/src/components/panel-color-settings/index.js b/gb-src/packages/block-editor/src/components/panel-color-settings/index.js new file mode 100644 index 0000000000000..aaa684579a4a8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/panel-color-settings/index.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { some } from 'lodash'; + +/** + * WordPress dependencies + */ +import { PanelBody, ColorIndicator } from '@wordpress/components'; +import { ifCondition } from '@wordpress/compose'; +import { sprintf, __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ColorPaletteControl from '../color-palette/control'; +import withColorContext from '../color-palette/with-color-context'; +import { getColorObjectByColorValue } from '../colors'; + +const hasCustomColorsDisabledForSetting = ( disableCustomColors, colorSetting ) => { + if ( colorSetting.disableCustomColors !== undefined ) { + return colorSetting.disableCustomColors; + } + return disableCustomColors; +}; + +const hasColorsToChooseInSetting = ( + colors = [], + disableCustomColors, + colorSetting ) => { + if ( ! hasCustomColorsDisabledForSetting( disableCustomColors, colorSetting ) ) { + return true; + } + return ( colorSetting.colors || colors ).length > 0; +}; + +const hasColorsToChoose = ( { colors, disableCustomColors, colorSettings } ) => { + return some( colorSettings, ( colorSetting ) => { + return hasColorsToChooseInSetting( + colors, + disableCustomColors, + colorSetting + ); + } ); +}; + +// translators: first %s: The type of color (e.g. background color), second %s: the color name or value (e.g. red or #ff0000) +const colorIndicatorAriaLabel = __( '(%s: %s)' ); + +const renderColorIndicators = ( colorSettings, colors ) => { + return colorSettings.map( + ( { value, label, colors: availableColors }, index ) => { + if ( ! value ) { + return null; + } + + const colorObject = getColorObjectByColorValue( + availableColors || colors, + value + ); + const colorName = colorObject && colorObject.name; + const ariaLabel = sprintf( + colorIndicatorAriaLabel, + label.toLowerCase(), + colorName || value + ); + + return ( + <ColorIndicator + key={ index } + colorValue={ value } + aria-label={ ariaLabel } + /> + ); + } + ); +}; + +// colorSettings is passed as an array of props so that it can be used for +// mapping both ColorIndicator and ColorPaletteControl components. Passing +// an array of components/nodes here wouldn't be feasible. +export const PanelColorSettings = ifCondition( hasColorsToChoose )( + ( { + children, + colors, + colorSettings, + disableCustomColors, + title, + ...props + } ) => { + const titleElement = ( + <span className="editor-panel-color-settings__panel-title block-editor-panel-color-settings__panel-title"> + { title } + { renderColorIndicators( colorSettings, colors ) } + </span> + ); + + return ( + <PanelBody + className="editor-panel-color-settings block-editor-panel-color-settings" + title={ titleElement } + { ...props } + > + { colorSettings.map( ( settings, index ) => ( + <ColorPaletteControl + key={ index } + { ...{ + colors, + disableCustomColors, + ...settings, + } } + /> + ) ) } + + { children } + </PanelBody> + ); + } +); + +export default withColorContext( PanelColorSettings ); diff --git a/gb-src/packages/block-editor/src/components/panel-color-settings/style.scss b/gb-src/packages/block-editor/src/components/panel-color-settings/style.scss new file mode 100644 index 0000000000000..3a88a7ecb7adb --- /dev/null +++ b/gb-src/packages/block-editor/src/components/panel-color-settings/style.scss @@ -0,0 +1,13 @@ +.block-editor-panel-color-settings { + .component-color-indicator { + vertical-align: text-bottom; + } + + &__panel-title .component-color-indicator { + display: inline-block; + } + + &.is-opened &__panel-title .component-color-indicator { + display: none; + } +} diff --git a/gb-src/packages/block-editor/src/components/panel-color-settings/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/panel-color-settings/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..569bd074451bd --- /dev/null +++ b/gb-src/packages/block-editor/src/components/panel-color-settings/test/__snapshots__/index.js.snap @@ -0,0 +1,194 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PanelColorSettings matches the snapshot 1`] = ` +<Component + colorSettings={ + Array [ + Object { + "label": "border color", + "onChange": [Function], + "value": "#000", + }, + Object { + "label": "background color", + "onChange": [Function], + "value": "#111", + }, + ] + } + colors={Array []} + title="Test Title" +/> +`; + +exports[`PanelColorSettings matches the snapshot 2`] = ` +<ForwardRef(PanelBody) + className="editor-panel-color-settings block-editor-panel-color-settings" + title={ + <span + className="editor-panel-color-settings__panel-title block-editor-panel-color-settings__panel-title" + > + Test Title + <ColorIndicator + aria-label="(border color: #000)" + colorValue="#000" + /> + <ColorIndicator + aria-label="(background color: #111)" + colorValue="#111" + /> + </span> + } +> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={Array []} + key="0" + label="border color" + onChange={[Function]} + value="#000" + /> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={Array []} + key="1" + label="background color" + onChange={[Function]} + value="#111" + /> +</ForwardRef(PanelBody)> +`; + +exports[`PanelColorSettings should render a color panel if at least one setting specifies some colors to choose 1`] = ` +<Component + colorSettings={ + Array [ + Object { + "colors": Array [ + Object { + "color": "#ff0000", + "name": "Red", + "slug": "red", + }, + ], + "label": "border color", + "onChange": [Function], + "value": "#000", + }, + Object { + "label": "background color", + "onChange": [Function], + "value": "#111", + }, + ] + } + colors={Array []} + disableCustomColors={true} + title="Test Title" +/> +`; + +exports[`PanelColorSettings should render a color panel if at least one setting specifies some colors to choose 2`] = ` +<ForwardRef(PanelBody) + className="editor-panel-color-settings block-editor-panel-color-settings" + title={ + <span + className="editor-panel-color-settings__panel-title block-editor-panel-color-settings__panel-title" + > + Test Title + <ColorIndicator + aria-label="(border color: #000)" + colorValue="#000" + /> + <ColorIndicator + aria-label="(background color: #111)" + colorValue="#111" + /> + </span> + } +> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={ + Array [ + Object { + "color": "#ff0000", + "name": "Red", + "slug": "red", + }, + ] + } + disableCustomColors={true} + key="0" + label="border color" + onChange={[Function]} + value="#000" + /> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={Array []} + disableCustomColors={true} + key="1" + label="background color" + onChange={[Function]} + value="#111" + /> +</ForwardRef(PanelBody)> +`; + +exports[`PanelColorSettings should render a color panel if at least one setting supports custom colors 1`] = ` +<Component + colorSettings={ + Array [ + Object { + "label": "border color", + "onChange": [Function], + "value": "#000", + }, + Object { + "disableCustomColors": false, + "label": "background color", + "onChange": [Function], + "value": "#111", + }, + ] + } + colors={Array []} + disableCustomColors={true} + title="Test Title" +/> +`; + +exports[`PanelColorSettings should render a color panel if at least one setting supports custom colors 2`] = ` +<ForwardRef(PanelBody) + className="editor-panel-color-settings block-editor-panel-color-settings" + title={ + <span + className="editor-panel-color-settings__panel-title block-editor-panel-color-settings__panel-title" + > + Test Title + <ColorIndicator + aria-label="(border color: #000)" + colorValue="#000" + /> + <ColorIndicator + aria-label="(background color: #111)" + colorValue="#111" + /> + </span> + } +> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={Array []} + disableCustomColors={true} + key="0" + label="border color" + onChange={[Function]} + value="#000" + /> + <WithColorContext(IfCondition(ColorPaletteControl)) + colors={Array []} + disableCustomColors={false} + key="1" + label="background color" + onChange={[Function]} + value="#111" + /> +</ForwardRef(PanelBody)> +`; diff --git a/gb-src/packages/block-editor/src/components/panel-color-settings/test/index.js b/gb-src/packages/block-editor/src/components/panel-color-settings/test/index.js new file mode 100644 index 0000000000000..aaa73a0c487da --- /dev/null +++ b/gb-src/packages/block-editor/src/components/panel-color-settings/test/index.js @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { PanelColorSettings } from '../'; + +describe( 'PanelColorSettings', () => { + it( 'matches the snapshot', () => { + const wrapper = shallow( + <PanelColorSettings + title="Test Title" + colors={ [] } + colorSettings={ [ + { + value: '#000', + onChange: noop, + label: 'border color', + }, + { + value: '#111', + onChange: noop, + label: 'background color', + }, + ] } + /> + ); + + expect( wrapper ).toMatchSnapshot(); + expect( wrapper.dive() ).toMatchSnapshot(); + } ); + + it( 'should not render anything if there are no colors to choose', () => { + const wrapper = shallow( + <PanelColorSettings + title="Test Title" + colors={ [] } + disableCustomColors={ true } + colorSettings={ [ + { + value: '#000', + onChange: noop, + label: 'border color', + }, + { + value: '#111', + onChange: noop, + label: 'background color', + }, + ] } + /> + ); + + expect( wrapper.type() ).toBeNull(); + } ); + + it( 'should render a color panel if at least one setting supports custom colors', () => { + const wrapper = shallow( + <PanelColorSettings + title="Test Title" + colors={ [] } + disableCustomColors={ true } + colorSettings={ [ + { + value: '#000', + onChange: noop, + label: 'border color', + }, + { + value: '#111', + onChange: noop, + label: 'background color', + disableCustomColors: false, + }, + ] } + /> + ); + expect( wrapper.type() ).not.toBeNull(); + expect( wrapper ).toMatchSnapshot(); + expect( wrapper.dive() ).toMatchSnapshot(); + } ); + + it( 'should render a color panel if at least one setting specifies some colors to choose', () => { + const wrapper = shallow( + <PanelColorSettings + title="Test Title" + colors={ [] } + disableCustomColors={ true } + colorSettings={ [ + { + value: '#000', + onChange: noop, + label: 'border color', + colors: [ { + slug: 'red', + name: 'Red', + color: '#ff0000', + } ], + }, + { + value: '#111', + onChange: noop, + label: 'background color', + }, + ] } + /> + ); + expect( wrapper.type() ).not.toBeNull(); + expect( wrapper ).toMatchSnapshot(); + expect( wrapper.dive() ).toMatchSnapshot(); + } ); + + it( 'should not render anything if none of the setting panels has colors to choose', () => { + const wrapper = shallow( + <PanelColorSettings + title="Test Title" + disableCustomColors={ false } + colorSettings={ [ + { + value: '#000', + onChange: noop, + label: 'border color', + colors: [], + disableCustomColors: true, + }, + { + value: '#111', + onChange: noop, + label: 'background color', + colors: [], + disableCustomColors: true, + }, + ] } + /> + ); + expect( wrapper.type() ).toBeNull(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/plain-text/README.md b/gb-src/packages/block-editor/src/components/plain-text/README.md new file mode 100644 index 0000000000000..74e8832d519c7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/plain-text/README.md @@ -0,0 +1,71 @@ +# `PlainText` + +Render an auto-growing textarea allow users to fill any textual content. + +## Properties + +### `value: string` + +*Required.* String value of the textarea + +### `onChange( value: string ): Function` + +*Required.* Called when the value changes. + +You can also pass any extra prop to the textarea rendered by this component. + +### `ref: Object` + +*Optional.* The component forwards the `ref` property to the `TextareaAutosize` component. + +## Example + +{% codetabs %} +{% ES5 %} +```js +wp.blocks.registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + type: 'string', + }, + }, + + edit: function( props ) { + return wp.element.createElement( wp.editor.PlainText, { + className: props.className, + value: props.attributes.content, + onChange: function( content ) { + props.setAttributes( { content: content } ); + }, + } ); + }, +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { PlainText } = wp.editor; + +registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + type: 'string', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + <PlainText + className={ className } + value={ attributes.content } + onChange={ ( content ) => setAttributes( { content } ) } + /> + ); + }, +} ); +``` +{% end %} diff --git a/gb-src/packages/block-editor/src/components/plain-text/index.js b/gb-src/packages/block-editor/src/components/plain-text/index.js new file mode 100644 index 0000000000000..b05e48ed60b61 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/plain-text/index.js @@ -0,0 +1,27 @@ + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * External dependencies + */ +import TextareaAutosize from 'react-autosize-textarea'; +import classnames from 'classnames'; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/plain-text/README.md + */ +const PlainText = forwardRef( ( { onChange, className, ...props }, ref ) => { + return ( + <TextareaAutosize + ref={ ref } + className={ classnames( 'editor-plain-text block-editor-plain-text', className ) } + onChange={ ( event ) => onChange( event.target.value ) } + { ...props } + /> + ); +} ); + +export default PlainText; diff --git a/gb-src/packages/block-editor/src/components/plain-text/index.native.js b/gb-src/packages/block-editor/src/components/plain-text/index.native.js new file mode 100644 index 0000000000000..9dd7eba346152 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/plain-text/index.native.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { TextInput, Platform } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export default class PlainText extends Component { + constructor() { + super( ...arguments ); + this.isIOS = Platform.OS === 'ios'; + } + + componentDidMount() { + // if isSelected is true, we should request the focus on this TextInput + if ( ( this._input.isFocused() === false ) && ( this._input.props.isSelected === true ) ) { + this.focus(); + } + } + + componentDidUpdate( prevProps ) { + if ( ! this.props.isSelected && prevProps.isSelected && this.isIOS ) { + this._input.blur(); + } + } + + focus() { + this._input.focus(); + } + + render() { + return ( + <TextInput + { ...this.props } + ref={ ( x ) => this._input = x } + className={ [ styles[ 'block-editor-plain-text' ], this.props.className ] } + onChange={ ( event ) => { + this.props.onChange( event.nativeEvent.text ); + } } + onFocus={ this.props.onFocus } // always assign onFocus as a props + onBlur={ this.props.onBlur } // always assign onBlur as a props + fontFamily={ this.props.fontFamily || ( styles[ 'block-editor-plain-text' ].fontFamily ) } + fontSize={ this.props.fontSize } + fontWeight={ this.props.fontWeight } + fontStyle={ this.props.fontStyle } + /> + ); + } +} diff --git a/gb-src/packages/block-editor/src/components/plain-text/style.native.scss b/gb-src/packages/block-editor/src/components/plain-text/style.native.scss new file mode 100644 index 0000000000000..5a9dcaa7445b1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/plain-text/style.native.scss @@ -0,0 +1,10 @@ + +.block-editor-plain-text { + font-family: $default-regular-font; + box-shadow: none; + + border-width: 0; + + padding: 0; + margin: 0; +} diff --git a/gb-src/packages/block-editor/src/components/plain-text/style.scss b/gb-src/packages/block-editor/src/components/plain-text/style.scss new file mode 100644 index 0000000000000..abcfaf5c8c1de --- /dev/null +++ b/gb-src/packages/block-editor/src/components/plain-text/style.scss @@ -0,0 +1,11 @@ +.block-editor .block-editor-plain-text { + box-shadow: none; + font-family: inherit; + font-size: inherit; + color: inherit; + line-height: inherit; + border: none; + padding: 0; + margin: 0; + width: 100%; +} diff --git a/gb-src/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js b/gb-src/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js new file mode 100644 index 0000000000000..f9e9650d93f0d --- /dev/null +++ b/gb-src/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + +export default function PreserveScrollInReorder() { + deprecated( 'PreserveScrollInReorder component', { + hint: 'This behavior is now built-in the block list', + } ); + return null; +} diff --git a/gb-src/packages/block-editor/src/components/provider/README.md b/gb-src/packages/block-editor/src/components/provider/README.md new file mode 100644 index 0000000000000..647c5854d0e21 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/provider/README.md @@ -0,0 +1,40 @@ +BlockEditorProvider +=================== + +BlockEditorProvider is a component which establishes a new block editing context, and serves as the entry point for a new block editor. It is implemented as a [controlled input](https://reactjs.org/docs/forms.html#controlled-components), expected to receive a value of a blocks array, calling `onChange` and/or `onInput` when the user interacts to change blocks in the editor. It is intended to be used as a wrapper component, where its children comprise the user interface through which a user modifies the blocks value, notably via other components made available from this `block-editor` module. + +## Props + +### `value` + +* **Type:** `Array` +* **Required** `no` + +The current array of blocks. + +### `onChange` + +* **Type:** `Function` +* **Required** `no` + +A callback invoked when the blocks have been modified in a persistent manner. Contrasted with `onInput`, a "persistent" change is one which is not an extension of a composed input. Any update to a distinct block or block attribute is treated as persistent. + +The distinction between these two callbacks is akin to the [differences between `input` and `change` events](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) in the DOM API: + +>The input event is fired every time the value of the element changes. **This is unlike the change event, which only fires when the value is committed**, such as by pressing the enter key, selecting a value from a list of options, and the like. + +In the context of an editor, an example usage of this distinction is for managing a history of blocks values (an "Undo"/"Redo" mechanism). While value updates should always be reflected immediately (`onInput`), you may only want history entries to reflect change milestones (`onChange`). + +### `onInput` + +* **Type:** `Function` +* **Required** `no` + +A callback invoked when the blocks have been modified in a non-persistent manner. Contrasted with `onChange`, a "non-persistent" change is one which is part of a composed input. Any sequence of updates to the same block attribute are treated as non-persistent, except for the first. + +### `children` + +* **Type:** `WPElement` +* **Required** `no` + +Children elements for which the BlockEditorProvider context should apply. diff --git a/gb-src/packages/block-editor/src/components/provider/index.js b/gb-src/packages/block-editor/src/components/provider/index.js new file mode 100644 index 0000000000000..488dd6244d8c6 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/provider/index.js @@ -0,0 +1,157 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import withRegistryProvider from './with-registry-provider'; + +class BlockEditorProvider extends Component { + componentDidMount() { + this.props.updateSettings( this.props.settings ); + this.props.resetBlocks( this.props.value ); + this.attachChangeObserver( this.props.registry ); + } + + componentDidUpdate( prevProps ) { + const { + settings, + updateSettings, + value, + resetBlocks, + registry, + } = this.props; + + if ( settings !== prevProps.settings ) { + updateSettings( settings ); + } + + if ( registry !== prevProps.registry ) { + this.attachChangeObserver( registry ); + } + + if ( this.isSyncingOutcomingValue !== null && this.isSyncingOutcomingValue === value ) { + // Skip block reset if the value matches expected outbound sync + // triggered by this component by a preceding change detection. + // Only skip if the value matches expectation, since a reset should + // still occur if the value is modified (not equal by reference), + // to allow that the consumer may apply modifications to reflect + // back on the editor. + this.isSyncingOutcomingValue = null; + } else if ( value !== prevProps.value ) { + // Reset changing value in all other cases than the sync described + // above. Since this can be reached in an update following an out- + // bound sync, unset the outbound value to avoid considering it in + // subsequent renders. + this.isSyncingOutcomingValue = null; + this.isSyncingIncomingValue = value; + resetBlocks( value ); + } + } + + componentWillUnmount() { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + } + + /** + * Given a registry object, overrides the default dispatch behavior for the + * `core/block-editor` store to interpret a state change and decide whether + * we should call `onChange` or `onInput` depending on whether the change + * is persistent or not. + * + * This needs to be done synchronously after state changes (instead of using + * `componentDidUpdate`) in order to avoid batching these changes. + * + * @param {WPDataRegistry} registry Registry from which block editor + * dispatch is to be overriden. + */ + attachChangeObserver( registry ) { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + + const { + getBlocks, + isLastBlockChangePersistent, + __unstableIsLastBlockChangeIgnored, + } = registry.select( 'core/block-editor' ); + + let blocks = getBlocks(); + let isPersistent = isLastBlockChangePersistent(); + + this.unsubscribe = registry.subscribe( () => { + const { + onChange = noop, + onInput = noop, + } = this.props; + + const newBlocks = getBlocks(); + const newIsPersistent = isLastBlockChangePersistent(); + + if ( + newBlocks !== blocks && ( + this.isSyncingIncomingValue || + __unstableIsLastBlockChangeIgnored() + ) + ) { + this.isSyncingIncomingValue = null; + blocks = newBlocks; + isPersistent = newIsPersistent; + return; + } + + if ( + newBlocks !== blocks || + // This happens when a previous input is explicitely marked as persistent. + ( newIsPersistent && ! isPersistent ) + ) { + // When knowing the blocks value is changing, assign instance + // value to skip reset in subsequent `componentDidUpdate`. + if ( newBlocks !== blocks ) { + this.isSyncingOutcomingValue = newBlocks; + } + + blocks = newBlocks; + isPersistent = newIsPersistent; + + if ( isPersistent ) { + onChange( blocks ); + } else { + onInput( blocks ); + } + } + } ); + } + + render() { + const { children } = this.props; + + return children; + } +} + +export default compose( [ + withRegistryProvider, + withDispatch( ( dispatch ) => { + const { + updateSettings, + resetBlocks, + } = dispatch( 'core/block-editor' ); + + return { + updateSettings, + resetBlocks, + }; + } ), +] )( BlockEditorProvider ); diff --git a/gb-src/packages/block-editor/src/components/provider/index.native.js b/gb-src/packages/block-editor/src/components/provider/index.native.js new file mode 100644 index 0000000000000..8e2f2e677397b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/provider/index.native.js @@ -0,0 +1,150 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { SlotFillProvider } from '@wordpress/components'; +import { withDispatch, RegistryConsumer } from '@wordpress/data'; +import { createHigherOrderComponent, compose } from '@wordpress/compose'; + +/** + * Higher-order component which renders the original component with the current + * registry context passed as its `registry` prop. + * + * @param {WPComponent} OriginalComponent Original component. + * + * @return {WPComponent} Enhanced component. + */ +const withRegistry = createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => ( + <RegistryConsumer> + { ( registry ) => ( + <OriginalComponent + { ...props } + registry={ registry } + /> + ) } + </RegistryConsumer> + ), + 'withRegistry' +); + +class BlockEditorProvider extends Component { + componentDidMount() { + this.props.updateSettings( this.props.settings ); + this.props.resetBlocks( this.props.value ); + this.attachChangeObserver( this.props.registry ); + } + + componentDidUpdate( prevProps ) { + const { + settings, + updateSettings, + value, + resetBlocks, + registry, + } = this.props; + + if ( settings !== prevProps.settings ) { + updateSettings( settings ); + } + + if ( registry !== prevProps.registry ) { + this.attachChangeObserver( registry ); + } + + if ( this.isSyncingOutcomingValue ) { + this.isSyncingOutcomingValue = false; + } else if ( value !== prevProps.value ) { + this.isSyncingIncomingValue = true; + resetBlocks( value ); + } + } + + componentWillUnmount() { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + } + + /** + * Given a registry object, overrides the default dispatch behavior for the + * `core/block-editor` store to interpret a state change and decide whether + * we should call `onChange` or `onInput` depending on whether the change + * is persistent or not. + * + * This needs to be done synchronously after state changes (instead of using + * `componentDidUpdate`) in order to avoid batching these changes. + * + * @param {WPDataRegistry} registry Registry from which block editor + * dispatch is to be overriden. + */ + attachChangeObserver( registry ) { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + + const { + getBlocks, + isLastBlockChangePersistent, + } = registry.select( 'core/block-editor' ); + + let blocks = getBlocks(); + let isPersistent = isLastBlockChangePersistent(); + + this.unsubscribe = registry.subscribe( () => { + const { + onChange, + onInput, + } = this.props; + const newBlocks = getBlocks(); + const newIsPersistent = isLastBlockChangePersistent(); + if ( newBlocks !== blocks && this.isSyncingIncomingValue ) { + this.isSyncingIncomingValue = false; + blocks = newBlocks; + isPersistent = newIsPersistent; + return; + } + + if ( + newBlocks !== blocks || + // This happens when a previous input is explicitely marked as persistent. + ( newIsPersistent && ! isPersistent ) + ) { + blocks = newBlocks; + isPersistent = newIsPersistent; + + this.isSyncingOutcomingValue = true; + if ( isPersistent ) { + onChange( blocks ); + } else { + onInput( blocks ); + } + } + } ); + } + + render() { + const { children } = this.props; + + return ( + <SlotFillProvider> + { children } + </SlotFillProvider> + ); + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { + updateSettings, + resetBlocks, + } = dispatch( 'core/block-editor' ); + + return { + updateSettings, + resetBlocks, + }; + } ), + withRegistry, +] )( BlockEditorProvider ); diff --git a/gb-src/packages/block-editor/src/components/provider/with-registry-provider.js b/gb-src/packages/block-editor/src/components/provider/with-registry-provider.js new file mode 100644 index 0000000000000..cdb3b7fe7b2de --- /dev/null +++ b/gb-src/packages/block-editor/src/components/provider/with-registry-provider.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { withRegistry, createRegistry, RegistryProvider } from '@wordpress/data'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { storeConfig } from '../../store'; +import applyMiddlewares from '../../store/middlewares'; + +const withRegistryProvider = createHigherOrderComponent( ( WrappedComponent ) => { + return withRegistry( ( { useSubRegistry = true, registry, ...props } ) => { + if ( ! useSubRegistry ) { + return <WrappedComponent registry={ registry } { ...props } />; + } + + const [ subRegistry, setSubRegistry ] = useState( null ); + useEffect( () => { + const newRegistry = createRegistry( {}, registry ); + const store = newRegistry.registerStore( 'core/block-editor', storeConfig ); + // This should be removed after the refactoring of the effects to controls. + applyMiddlewares( store ); + setSubRegistry( newRegistry ); + }, [ registry ] ); + + if ( ! subRegistry ) { + return null; + } + + return ( + <RegistryProvider value={ subRegistry }> + <WrappedComponent registry={ subRegistry } { ...props } /> + </RegistryProvider> + ); + } ); +}, 'withRegistryProvider' ); + +export default withRegistryProvider; diff --git a/gb-src/packages/block-editor/src/components/rich-text/README.md b/gb-src/packages/block-editor/src/components/rich-text/README.md new file mode 100644 index 0000000000000..ed0f15d38ec01 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/README.md @@ -0,0 +1,177 @@ +# `RichText` + +Render a rich [`contenteditable` input](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content), providing users with the option to format the content. + +## Properties + +### `value: String` + +*Required.* HTML string to make editable. The HTML should be valid, and valid inside the `tagName`, if provided. + +### `onChange( value: String ): Function` + +*Required.* Called when the value changes. + +### `tagName: String` + +*Default: `div`.* The [tag name](https://www.w3.org/TR/html51/syntax.html#tag-name) of the editable element. Elements that display inline are not supported. + +### `placeholder: String` + +*Optional.* Placeholder text to show when the field is empty, similar to the + [`input` and `textarea` attribute of the same name](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/HTML5_updates#The_placeholder_attribute). + +### `keepPlaceholderOnFocus: Boolean` + +*Optional.* Show placeholder even when selected/focused, as long as there is no content. + +### `multiline: Boolean | String` + +*Optional.* By default, a line break will be inserted on <kbd>Enter</kbd>. If the editable field can contain multiple paragraphs, this property can be set to create new paragraphs on <kbd>Enter</kbd>. + +### `onSplit( value: String ): Function` + +*Optional.* Called when the content can be split, where `value` is a piece of content being split off. Here you should create a new block with that content and return it. Note that you also need to provide `onReplace` in order for this to take any effect. + +### `onReplace( blocks: Array ): Function` + +*Optional.* Called when the `RichText` instance can be replaced with the given blocks. + +### `onMerge( forward: Boolean ): Function` + +*Optional.* Called when blocks can be merged. `forward` is true when merging with the next block, false when merging with the previous block. + +### `onRemove( forward: Boolean ): Function` + +*Optional.* Called when the block can be removed. `forward` is true when the selection is expected to move to the next block, false to the previous block. + +### `allowedFormats: Array` + +*Optional.* By default, all registered formats are allowed. This setting can be used to fine-tune the allowed formats. Example: `[ 'core/bold', 'core/link' ]`. + +### `withoutInteractiveFormatting: Boolean` + +*Optional.* By default, all formatting controls are present. This setting can be used to remove formatting controls that would make content [interactive](https://html.spec.whatwg.org/multipage/dom.html#interactive-content). This is useful if you want to make content that is already interactive editable. + +### `isSelected: Boolean` + +*Optional.* Whether to show the input is selected or not in order to show the formatting controls. By default it renders the controls when the block is selected. + +### `autocompleters: Array<Completer>` + +*Optional.* A list of autocompleters to use instead of the default. + +## RichText.Content + +`RichText.Content` should be used in the `save` function of your block to correctly save rich text content. + +## Example + +{% codetabs %} +{% ES5 %} +```js +wp.blocks.registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + source: 'html', + selector: 'h2', + }, + }, + + edit: function( props ) { + return wp.element.createElement( wp.editor.RichText, { + tagName: 'h2', + className: props.className, + value: props.attributes.content, + onChange: function( content ) { + props.setAttributes( { content: content } ); + } + } ); + }, + + save: function( props ) { + return wp.element.createElement( wp.editor.RichText.Content, { + tagName: 'h2', value: props.attributes.content + } ); + } +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { RichText } = wp.editor; + +registerBlockType( /* ... */, { + // ... + + attributes: { + content: { + source: 'html', + selector: 'h2', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + <RichText + tagName="h2" + className={ className } + value={ attributes.content } + onChange={ ( content ) => setAttributes( { content } ) } + /> + ); + }, + + save( { attributes } ) { + return <RichText.Content tagName="h2" value={ attributes.content } />; + } +} ); +``` +{% end %} + +## RichTextToolbarButton + +Slot to extend the format toolbar. Use it in the edit function of a `registerFormatType` call to surface the format to the UI. + +### Example + +{% codetabs %} +{% ES5 %} +```js +wp.richText.registerFormatType( /* ... */, { + /* ... */ + edit: function( props ) { + return wp.element.createElement( + wp.editor.RichTextToolbarButton, { + icon: 'editor-code', + title: 'My formatting button', + onClick: function() { /* ... */ } + isActive: props.isActive, + } ); + }, + /* ... */ +} ); +``` +{% ESNext %} +```js +import { registerFormatType } from 'wp-rich-text'; +import { richTextToolbarButton } from 'wp-editor'; + +registerFormatType( /* ... */, { + /* ... */ + edit( { isActive } ) { + return ( + <RichTextToolbarButton + icon={ 'editor-code' } + title={ 'My formatting button' } + onClick={ /* ... */ } + isActive={ isActive } + /> + ); + }, + /* ... */ +} ); +``` +{% end %} diff --git a/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.js b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.js new file mode 100644 index 0000000000000..0aed5916c0bc4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ + +import { orderBy } from 'lodash'; + +/** + * WordPress dependencies + */ + +import { __ } from '@wordpress/i18n'; +import { Toolbar, Slot, DropdownMenu } from '@wordpress/components'; + +const POPOVER_PROPS = { + position: 'bottom left', +}; + +const FormatToolbar = () => { + return ( + <div className="editor-format-toolbar block-editor-format-toolbar"> + <Toolbar> + { [ 'bold', 'italic', 'link' ].map( ( format ) => + <Slot name={ `RichText.ToolbarControls.${ format }` } key={ format } /> + ) } + <Slot name="RichText.ToolbarControls"> + { ( fills ) => fills.length !== 0 && + <DropdownMenu + icon={ false } + label={ __( 'More rich text controls' ) } + controls={ orderBy( fills.map( ( [ { props } ] ) => props ), 'title' ) } + popoverProps={ POPOVER_PROPS } + /> + } + </Slot> + </Toolbar> + </div> + ); +}; + +export default FormatToolbar; diff --git a/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.native.js b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.native.js new file mode 100644 index 0000000000000..d39f9b4668eb4 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/index.native.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ + +import { Toolbar, Slot } from '@wordpress/components'; + +const FormatToolbar = () => { + return ( + <Toolbar> + { [ 'bold', 'italic', 'link' ].map( ( format ) => + <Slot name={ `RichText.ToolbarControls.${ format }` } key={ format } /> + ) } + <Slot name="RichText.ToolbarControls" /> + </Toolbar> + ); +}; + +export default FormatToolbar; diff --git a/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/style.scss b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/style.scss new file mode 100644 index 0000000000000..dc493c3b0c845 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/format-toolbar/style.scss @@ -0,0 +1,13 @@ +.block-editor-format-toolbar { + display: flex; + flex-shrink: 0; +} + +.block-editor-format-toolbar__selection-position { + position: absolute; + transform: translateX(-50%); +} + +.block-editor-format-toolbar .components-dropdown-menu__toggle .components-dropdown-menu__indicator::after { + margin: 7px; +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/index.js b/gb-src/packages/block-editor/src/components/rich-text/index.js new file mode 100644 index 0000000000000..48ab0ec322fc2 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/index.js @@ -0,0 +1,558 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { RawHTML, Component } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { pasteHandler, children as childrenSource, getBlockTransforms, findTransform } from '@wordpress/blocks'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { + __experimentalRichText as RichText, + __unstableCreateElement, + isEmpty, + __unstableIsEmptyLine as isEmptyLine, + insert, + __unstableInsertLineSeparator as insertLineSeparator, + create, + replace, + split, + __UNSTABLE_LINE_SEPARATOR as LINE_SEPARATOR, + toHTMLString, + slice, +} from '@wordpress/rich-text'; +import { withFilters, IsolatedEventContainer } from '@wordpress/components'; +import { createBlobURL } from '@wordpress/blob'; +import deprecated from '@wordpress/deprecated'; +import { isURL } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import Autocomplete from '../autocomplete'; +import BlockFormatControls from '../block-format-controls'; +import FormatToolbar from './format-toolbar'; +import { withBlockEditContext } from '../block-edit/context'; +import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; + +const requestIdleCallback = window.requestIdleCallback || + function fallbackRequestIdleCallback( fn ) { + window.setTimeout( fn, 100 ); + }; + +const wrapperClasses = 'editor-rich-text block-editor-rich-text'; +const classes = 'editor-rich-text__editable block-editor-rich-text__editable'; + +/** + * Get the multiline tag based on the multiline prop. + * + * @param {?(string|boolean)} multiline The multiline prop. + * + * @return {?string} The multiline tag. + */ +function getMultilineTag( multiline ) { + if ( multiline !== true && multiline !== 'p' && multiline !== 'li' ) { + return; + } + + return multiline === true ? 'p' : multiline; +} + +class RichTextWrapper extends Component { + constructor() { + super( ...arguments ); + this.onEnter = this.onEnter.bind( this ); + this.onSplit = this.onSplit.bind( this ); + this.onPaste = this.onPaste.bind( this ); + this.onDelete = this.onDelete.bind( this ); + this.inputRule = this.inputRule.bind( this ); + this.markAutomaticChange = this.markAutomaticChange.bind( this ); + } + + onEnter( { value, onChange, shiftKey } ) { + const { onReplace, onSplit, multiline } = this.props; + const canSplit = onReplace && onSplit; + + if ( onReplace ) { + const transforms = getBlockTransforms( 'from' ) + .filter( ( { type } ) => type === 'enter' ); + const transformation = findTransform( transforms, ( item ) => { + return item.regExp.test( value.text ); + } ); + + if ( transformation ) { + onReplace( [ + transformation.transform( { content: value.text } ), + ] ); + this.markAutomaticChange(); + } + } + + if ( multiline ) { + if ( shiftKey ) { + onChange( insert( value, '\n' ) ); + } else if ( canSplit && isEmptyLine( value ) ) { + this.onSplit( value ); + } else { + onChange( insertLineSeparator( value ) ); + } + } else if ( shiftKey || ! canSplit ) { + onChange( insert( value, '\n' ) ); + } else { + this.onSplit( value ); + } + } + + onDelete( { value, isReverse } ) { + const { onMerge, onRemove } = this.props; + + if ( onMerge ) { + onMerge( ! isReverse ); + } + + // Only handle remove on Backspace. This serves dual-purpose of being + // an intentional user interaction distinguishing between Backspace and + // Delete to remove the empty field, but also to avoid merge & remove + // causing destruction of two fields (merge, then removed merged). + if ( onRemove && isEmpty( value ) && isReverse ) { + onRemove( ! isReverse ); + } + } + + onPaste( { value, onChange, html, plainText, image } ) { + const { + onReplace, + onSplit, + tagName, + canUserUseUnfilteredHTML, + multiline, + __unstableEmbedURLOnPaste, + } = this.props; + + if ( image && ! html ) { + const file = image.getAsFile ? image.getAsFile() : image; + const content = pasteHandler( { + HTML: `<img src="${ createBlobURL( file ) }">`, + mode: 'BLOCKS', + tagName, + } ); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Received item:\n\n', file ); + + if ( onReplace && isEmpty( value ) ) { + onReplace( content ); + } else { + this.onSplit( value, content ); + } + + return; + } + + let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; + + if ( + __unstableEmbedURLOnPaste && + isEmpty( value ) && + isURL( plainText.trim() ) + ) { + mode = 'BLOCKS'; + } + + const content = pasteHandler( { + HTML: html, + plainText, + mode, + tagName, + canUserUseUnfilteredHTML, + } ); + + if ( typeof content === 'string' ) { + let valueToInsert = create( { html: content } ); + + // If the content should be multiline, we should process text + // separated by a line break as separate lines. + if ( multiline ) { + valueToInsert = replace( valueToInsert, /\n+/g, LINE_SEPARATOR ); + } + + onChange( insert( value, valueToInsert ) ); + } else if ( content.length > 0 ) { + if ( onReplace && isEmpty( value ) ) { + onReplace( content ); + } else { + this.onSplit( value, content ); + } + } + } + + /** + * Signals to the RichText owner that the block can be replaced with two + * blocks as a result of splitting the block by pressing enter, or with + * blocks as a result of splitting the block by pasting block content in the + * instance. + * + * @param {Object} record The rich text value to split. + * @param {Array} pastedBlocks The pasted blocks to insert, if any. + */ + onSplit( record, pastedBlocks = [] ) { + const { + onReplace, + onSplit, + __unstableOnSplitMiddle: onSplitMiddle, + multiline, + } = this.props; + + if ( ! onReplace || ! onSplit ) { + return; + } + + const blocks = []; + const [ before, after ] = split( record ); + const hasPastedBlocks = pastedBlocks.length > 0; + const multilineTag = getMultilineTag( multiline ); + + // Create a block with the content before the caret if there's no pasted + // blocks, or if there are pasted blocks and the value is not empty. + // We do not want a leading empty block on paste, but we do if split + // with e.g. the enter key. + if ( ! hasPastedBlocks || ! isEmpty( before ) ) { + blocks.push( onSplit( toHTMLString( { + value: before, + multilineTag, + } ) ) ); + } + + if ( hasPastedBlocks ) { + blocks.push( ...pastedBlocks ); + } else if ( onSplitMiddle ) { + blocks.push( onSplitMiddle() ); + } + + // If there's pasted blocks, append a block with the content after the + // caret. Otherwise, do append and empty block if there is no + // `onSplitMiddle` prop, but if there is and the content is empty, the + // middle block is enough to set focus in. + if ( hasPastedBlocks || ! onSplitMiddle || ! isEmpty( after ) ) { + blocks.push( onSplit( toHTMLString( { + value: after, + multilineTag, + } ) ) ); + } + + // If there are pasted blocks, set the selection to the last one. + // Otherwise, set the selection to the second block. + const indexToSelect = hasPastedBlocks ? blocks.length - 1 : 1; + + onReplace( blocks, indexToSelect ); + } + + inputRule( value, valueToFormat ) { + const { onReplace } = this.props; + + if ( ! onReplace ) { + return; + } + + const { start, text } = value; + const characterBefore = text.slice( start - 1, start ); + + // The character right before the caret must be a plain space. + if ( characterBefore !== ' ' ) { + return; + } + + const trimmedTextBefore = text.slice( 0, start ).trim(); + const prefixTransforms = getBlockTransforms( 'from' ) + .filter( ( { type } ) => type === 'prefix' ); + const transformation = findTransform( prefixTransforms, ( { prefix } ) => { + return trimmedTextBefore === prefix; + } ); + + if ( ! transformation ) { + return; + } + + const content = valueToFormat( slice( value, start, text.length ) ); + const block = transformation.transform( content ); + + onReplace( [ block ] ); + this.markAutomaticChange(); + } + + getAllowedFormats() { + const { allowedFormats, formattingControls } = this.props; + + if ( ! allowedFormats && ! formattingControls ) { + return; + } + + if ( allowedFormats ) { + return allowedFormats; + } + + deprecated( 'wp.blockEditor.RichText formattingControls prop', { + alternative: 'allowedFormats', + } ); + + return formattingControls.map( ( name ) => `core/${ name }` ); + } + + /** + * Marks the last change as an automatic change at the next idle period to + * ensure all selection changes have been recorded. + */ + markAutomaticChange() { + requestIdleCallback( () => { + this.props.markAutomaticChange(); + } ); + } + + render() { + const { + children, + tagName, + value: originalValue, + onChange: originalOnChange, + selectionStart, + selectionEnd, + onSelectionChange, + multiline, + inlineToolbar, + wrapperClassName, + className, + autocompleters, + onReplace, + isCaretWithinFormattedText, + onEnterFormattedText, + onExitFormattedText, + isSelected: originalIsSelected, + onCreateUndoLevel, + // eslint-disable-next-line no-unused-vars + markAutomaticChange, + didAutomaticChange, + undo, + placeholder, + keepPlaceholderOnFocus, + // eslint-disable-next-line no-unused-vars + allowedFormats, + withoutInteractiveFormatting, + // eslint-disable-next-line no-unused-vars + onRemove, + // eslint-disable-next-line no-unused-vars + onMerge, + // eslint-disable-next-line no-unused-vars + onSplit, + // eslint-disable-next-line no-unused-vars + canUserUseUnfilteredHTML, + // eslint-disable-next-line no-unused-vars + clientId, + // eslint-disable-next-line no-unused-vars + identifier, + // eslint-disable-next-line no-unused-vars + instanceId, + // To do: find a better way to implicitly inherit props. + start, + reversed, + // From experimental filter. To do: pick props instead. + ...experimentalProps + } = this.props; + const multilineTag = getMultilineTag( multiline ); + + const adjustedAllowedFormats = this.getAllowedFormats(); + const hasFormats = ! adjustedAllowedFormats || adjustedAllowedFormats.length > 0; + let adjustedValue = originalValue; + let adjustedOnChange = originalOnChange; + + // Handle deprecated format. + if ( Array.isArray( originalValue ) ) { + adjustedValue = childrenSource.toHTML( originalValue ); + adjustedOnChange = ( newValue ) => originalOnChange( childrenSource.fromDOM( + __unstableCreateElement( document, newValue ).childNodes + ) ); + } + + const content = ( + <RichText + { ...experimentalProps } + value={ adjustedValue } + onChange={ adjustedOnChange } + selectionStart={ selectionStart } + selectionEnd={ selectionEnd } + onSelectionChange={ onSelectionChange } + tagName={ tagName } + className={ classnames( classes, className, { + 'is-selected': originalIsSelected, + 'keep-placeholder-on-focus': keepPlaceholderOnFocus, + } ) } + placeholder={ placeholder } + allowedFormats={ adjustedAllowedFormats } + withoutInteractiveFormatting={ withoutInteractiveFormatting } + onEnter={ this.onEnter } + onDelete={ this.onDelete } + onPaste={ this.onPaste } + __unstableIsSelected={ originalIsSelected } + __unstableInputRule={ this.inputRule } + __unstableMultilineTag={ multilineTag } + __unstableIsCaretWithinFormattedText={ isCaretWithinFormattedText } + __unstableOnEnterFormattedText={ onEnterFormattedText } + __unstableOnExitFormattedText={ onExitFormattedText } + __unstableOnCreateUndoLevel={ onCreateUndoLevel } + __unstableMarkAutomaticChange={ this.markAutomaticChange } + __unstableDidAutomaticChange={ didAutomaticChange } + __unstableUndo={ undo } + > + { ( { isSelected, value, onChange, Editable } ) => + <> + { children && children( { value, onChange } ) } + { isSelected && ! inlineToolbar && hasFormats && ( + <BlockFormatControls> + <FormatToolbar /> + </BlockFormatControls> + ) } + { isSelected && inlineToolbar && hasFormats && ( + <IsolatedEventContainer + className="editor-rich-text__inline-toolbar block-editor-rich-text__inline-toolbar" + > + <FormatToolbar /> + </IsolatedEventContainer> + ) } + { isSelected && <RemoveBrowserShortcuts /> } + <Autocomplete + onReplace={ onReplace } + completers={ autocompleters } + record={ value } + onChange={ onChange } + isSelected={ isSelected } + > + { ( { listBoxId, activeId, onKeyDown } ) => + <Editable + aria-autocomplete={ listBoxId ? 'list' : undefined } + aria-owns={ listBoxId } + aria-activedescendant={ activeId } + start={ start } + reversed={ reversed } + onKeyDown={ onKeyDown } + /> + } + </Autocomplete> + </> + } + </RichText> + ); + + return ( + <div className={ classnames( wrapperClasses, wrapperClassName ) }> + { content } + </div> + ); + } +} + +const RichTextContainer = compose( [ + withInstanceId, + withBlockEditContext( ( { clientId } ) => ( { clientId } ) ), + withSelect( ( select, { + clientId, + instanceId, + identifier = instanceId, + isSelected, + } ) => { + const { + isCaretWithinFormattedText, + getSelectionStart, + getSelectionEnd, + getSettings, + didAutomaticChange, + } = select( 'core/block-editor' ); + + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + const { __experimentalCanUserUseUnfilteredHTML } = getSettings(); + if ( isSelected === undefined ) { + isSelected = ( + selectionStart.clientId === clientId && + selectionStart.attributeKey === identifier + ); + } else if ( isSelected ) { + isSelected = selectionStart.clientId === clientId; + } + + return { + canUserUseUnfilteredHTML: __experimentalCanUserUseUnfilteredHTML, + isCaretWithinFormattedText: isCaretWithinFormattedText(), + selectionStart: isSelected ? selectionStart.offset : undefined, + selectionEnd: isSelected ? selectionEnd.offset : undefined, + isSelected, + didAutomaticChange: didAutomaticChange(), + }; + } ), + withDispatch( ( dispatch, { + clientId, + instanceId, + identifier = instanceId, + } ) => { + const { + __unstableMarkLastChangeAsPersistent, + enterFormattedText, + exitFormattedText, + selectionChange, + __unstableMarkAutomaticChange, + } = dispatch( 'core/block-editor' ); + const { undo } = dispatch( 'core/editor' ); + + return { + onCreateUndoLevel: __unstableMarkLastChangeAsPersistent, + onEnterFormattedText: enterFormattedText, + onExitFormattedText: exitFormattedText, + onSelectionChange( start, end ) { + selectionChange( clientId, identifier, start, end ); + }, + markAutomaticChange: __unstableMarkAutomaticChange, + undo, + }; + } ), + withFilters( 'experimentalRichText' ), +] )( RichTextWrapper ); + +RichTextContainer.Content = ( { value, tagName: Tag, multiline, ...props } ) => { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + value = childrenSource.toHTML( value ); + } + + const MultilineTag = getMultilineTag( multiline ); + + if ( ! value && MultilineTag ) { + value = `<${ MultilineTag }></${ MultilineTag }>`; + } + + const content = <RawHTML>{ value }</RawHTML>; + + if ( Tag ) { + return <Tag { ...omit( props, [ 'format' ] ) }>{ content }</Tag>; + } + + return content; +}; + +RichTextContainer.isEmpty = ( value ) => { + return ! value || value.length === 0; +}; + +RichTextContainer.Content.defaultProps = { + format: 'string', + value: '', +}; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/rich-text/README.md + */ +export default RichTextContainer; +export { RichTextShortcut } from './shortcut'; +export { RichTextToolbarButton } from './toolbar-button'; +export { __unstableRichTextInputEvent } from './input-event'; diff --git a/gb-src/packages/block-editor/src/components/rich-text/index.native.js b/gb-src/packages/block-editor/src/components/rich-text/index.native.js new file mode 100644 index 0000000000000..30e882ed49d80 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/index.native.js @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { pasteHandler, isUnmodifiedDefaultBlock } from '@wordpress/blocks'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { RichText } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import Autocomplete from '../autocomplete'; +import BlockFormatControls from '../block-format-controls'; +import FormatToolbar from './format-toolbar'; +import { withBlockEditContext } from '../block-edit/context'; + +const wrapperClasses = 'editor-rich-text block-editor-rich-text'; +const classes = 'editor-rich-text__editable block-editor-rich-text__editable'; + +function RichTextWraper( { + children, + tagName, + value: originalValue, + onChange: originalOnChange, + selectionStart, + selectionEnd, + onSelectionChange, + multiline, + inlineToolbar, + wrapperClassName, + className, + autocompleters, + onReplace, + onRemove, + onMerge, + onSplit, + isCaretWithinFormattedText, + onEnterFormattedText, + onExitFormattedText, + canUserUseUnfilteredHTML, + isSelected: originalIsSelected, + onCreateUndoLevel, + placeholder, + // From experimental filter. + ...experimentalProps +} ) { + const adjustedValue = originalValue; + const adjustedOnChange = originalOnChange; + + return ( + <RichText + { ...experimentalProps } + value={ adjustedValue } + onChange={ adjustedOnChange } + selectionStart={ selectionStart } + selectionEnd={ selectionEnd } + onSelectionChange={ onSelectionChange } + tagName={ tagName } + wrapperClassName={ classnames( wrapperClasses, wrapperClassName ) } + className={ classnames( classes, className ) } + placeholder={ placeholder } + __unstableIsSelected={ originalIsSelected } + //__unstablePatterns={ getPatterns() } + //__unstableEnterPatterns={ getEnterPatterns() } + __unstablePasteHandler={ pasteHandler } + __unstableAutocomplete={ Autocomplete } + __unstableAutocompleters={ autocompleters } + __unstableOnReplace={ onReplace } + __unstableOnRemove={ onRemove } + __unstableOnMerge={ onMerge } + __unstableOnSplit={ onSplit } + __unstableMultiline={ multiline } + __unstableIsCaretWithinFormattedText={ isCaretWithinFormattedText } + __unstableOnEnterFormattedText={ onEnterFormattedText } + __unstableOnExitFormattedText={ onExitFormattedText } + __unstableCanUserUseUnfilteredHTML={ canUserUseUnfilteredHTML } + __unstableOnCreateUndoLevel={ onCreateUndoLevel } + > + { ( { isSelected, value, onChange } ) => + <View> + { children && children( { value, onChange } ) } + { isSelected && ! inlineToolbar && ( + <BlockFormatControls> + <FormatToolbar /> + </BlockFormatControls> + ) } + </View> + } + </RichText> + ); +} + +const RichTextContainer = compose( [ + withInstanceId, + withBlockEditContext( ( { clientId, onCaretVerticalPositionChange, isSelected }, ownProps ) => { + return { + clientId, + blockIsSelected: ownProps.isSelected !== undefined ? ownProps.isSelected : isSelected, + onCaretVerticalPositionChange, + }; + } ), + withSelect( ( select, { + clientId, + instanceId, + identifier = instanceId, + isSelected, + blockIsSelected, + } ) => { + const { getFormatTypes } = select( 'core/rich-text' ); + const { + getSelectionStart, + getSelectionEnd, + __unstableGetBlockWithoutInnerBlocks, + } = select( 'core/block-editor' ); + + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + + if ( isSelected === undefined ) { + isSelected = ( + selectionStart.clientId === clientId && + selectionStart.attributeKey === identifier + ); + } + + // If the block of this RichText is unmodified then it's a candidate for replacing when adding a new block. + // In order to fix https://github.com/wordpress-mobile/gutenberg-mobile/issues/1126, let's blur on unmount in that case. + // This apparently assumes functionality the BlockHlder actually + const block = clientId && __unstableGetBlockWithoutInnerBlocks( clientId ); + const shouldBlurOnUnmount = block && isSelected && isUnmodifiedDefaultBlock( block ); + + return { + formatTypes: getFormatTypes(), + selectionStart: isSelected ? selectionStart.offset : undefined, + selectionEnd: isSelected ? selectionEnd.offset : undefined, + isSelected, + blockIsSelected, + shouldBlurOnUnmount, + }; + } ), + withDispatch( ( dispatch, { + clientId, + instanceId, + identifier = instanceId, + } ) => { + const { + __unstableMarkLastChangeAsPersistent, + selectionChange, + } = dispatch( 'core/block-editor' ); + + return { + onCreateUndoLevel: __unstableMarkLastChangeAsPersistent, + onSelectionChange( start, end ) { + selectionChange( clientId, identifier, start, end ); + }, + }; + } ), +] )( RichTextWraper ); + +RichTextContainer.Content = ( { value, format, tagName: Tag, multiline, ...props } ) => { + let content; + let html = value; + let MultilineTag; + + if ( multiline === true || multiline === 'p' || multiline === 'li' ) { + MultilineTag = multiline === true ? 'p' : multiline; + } + + if ( ! html && MultilineTag ) { + html = `<${ MultilineTag }></${ MultilineTag }>`; + } + + switch ( format ) { + case 'string': + content = <RawHTML>{ html }</RawHTML>; + break; + } + + if ( Tag ) { + return <Tag { ...props }>{ content }</Tag>; + } + + return content; +}; + +RichTextContainer.isEmpty = ( value = '' ) => { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + return ! value || value.length === 0; + } + + return value.length === 0; +}; + +RichTextContainer.Content.defaultProps = { + format: 'string', + value: '', +}; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/rich-text/README.md + */ +export default RichTextContainer; +export { RichTextShortcut } from './shortcut'; +export { RichTextToolbarButton } from './toolbar-button'; +export { __unstableRichTextInputEvent } from './input-event'; diff --git a/gb-src/packages/block-editor/src/components/rich-text/input-event.js b/gb-src/packages/block-editor/src/components/rich-text/input-event.js new file mode 100644 index 0000000000000..016e370095681 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/input-event.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +export class __unstableRichTextInputEvent extends Component { + constructor() { + super( ...arguments ); + + this.onInput = this.onInput.bind( this ); + } + + onInput( event ) { + if ( event.inputType === this.props.inputType ) { + this.props.onInput(); + } + } + + componentDidMount() { + document.addEventListener( 'input', this.onInput, true ); + } + + componentWillUnmount() { + document.removeEventListener( 'input', this.onInput, true ); + } + + render() { + return null; + } +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/input-event.native.js b/gb-src/packages/block-editor/src/components/rich-text/input-event.native.js new file mode 100644 index 0000000000000..f888365ccba59 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/input-event.native.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +export class __unstableRichTextInputEvent extends Component { + render() { + return null; + } +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/remove-browser-shortcuts.js b/gb-src/packages/block-editor/src/components/rich-text/remove-browser-shortcuts.js new file mode 100644 index 0000000000000..165566a5bbbd1 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/remove-browser-shortcuts.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { fromPairs } from 'lodash'; + +/** + * WordPress dependencies + */ +import { rawShortcut } from '@wordpress/keycodes'; +import { KeyboardShortcuts } from '@wordpress/components'; + +/** + * Set of keyboard shortcuts handled internally by RichText. + * + * @type {Array} + */ +const HANDLED_SHORTCUTS = [ + rawShortcut.primary( 'z' ), + rawShortcut.primaryShift( 'z' ), + rawShortcut.primary( 'y' ), +]; + +/** + * An instance of a KeyboardShortcuts element pre-bound for the handled + * shortcuts. Since shortcuts never change, the element can be considered + * static, and can be skipped in reconciliation. + * + * @type {WPElement} + */ +const SHORTCUTS_ELEMENT = ( + <KeyboardShortcuts + bindGlobal + shortcuts={ fromPairs( HANDLED_SHORTCUTS.map( ( shortcut ) => { + return [ shortcut, ( event ) => event.preventDefault() ]; + } ) ) } + /> +); + +/** + * Component which registered keyboard event handlers to prevent default + * behaviors for key combinations otherwise handled internally by RichText. + * + * @return {WPElement} WordPress element. + */ +export const RemoveBrowserShortcuts = () => SHORTCUTS_ELEMENT; diff --git a/gb-src/packages/block-editor/src/components/rich-text/shortcut.js b/gb-src/packages/block-editor/src/components/rich-text/shortcut.js new file mode 100644 index 0000000000000..ade272214b6ba --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/shortcut.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { KeyboardShortcuts } from '@wordpress/components'; +import { rawShortcut } from '@wordpress/keycodes'; + +export class RichTextShortcut extends Component { + constructor() { + super( ...arguments ); + + this.onUse = this.onUse.bind( this ); + } + + onUse() { + this.props.onUse(); + return false; + } + + render() { + const { character, type } = this.props; + + return ( + <KeyboardShortcuts + bindGlobal + shortcuts={ { + [ rawShortcut[ type ]( character ) ]: this.onUse, + } } + /> + ); + } +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/shortcut.native.js b/gb-src/packages/block-editor/src/components/rich-text/shortcut.native.js new file mode 100644 index 0000000000000..61a62170f2768 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/shortcut.native.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +export class RichTextShortcut extends Component { + render() { + return null; + } +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/style.scss b/gb-src/packages/block-editor/src/components/rich-text/style.scss new file mode 100644 index 0000000000000..ac190819bc296 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/style.scss @@ -0,0 +1,82 @@ +.block-editor-rich-text { + // This is needed to position the formatting toolbar. + position: relative; +} + +.block-editor-rich-text__editable { + > p:first-child { + margin-top: 0; + } + + a { + color: $blue-medium-700; + } + + code { + padding: 2px; + border-radius: 2px; + color: $dark-gray-800; + background: $light-gray-200; + font-family: $editor-html-font; + font-size: inherit; // This is necessary to override upstream CSS. + + .is-multi-selected & { + background: darken($blue-medium-highlight, 15%); + } + } + + &:focus { + // Removes outline added by the browser. + outline: none; + + *[data-rich-text-format-boundary] { + border-radius: 2px; + } + } + + [data-rich-text-placeholder] { + pointer-events: none; + } + + [data-rich-text-placeholder]::after { + content: attr(data-rich-text-placeholder); + // Use opacity to work in various editor styles. + // We don't specify the color here, because blocks or editor styles might provide their own. + opacity: 0.62; + } + + // Could be unset for individual rich text instances. + &.is-selected:not(.keep-placeholder-on-focus) [data-rich-text-placeholder]::after { + display: none; + } +} + +// Captions may have lighter (gray) text, or be shown on a range of different background luminosites. +// To ensure legibility, we increase the default placeholder opacity to ensure contrast. +figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before { + opacity: 0.8; +} + +.block-editor-rich-text__inline-toolbar { + display: flex; + justify-content: center; + position: absolute; + top: -$block-controls-height - 4px; + line-height: 0; + left: 0; + right: 0; + z-index: 1; + + ul.components-toolbar { + box-shadow: $shadow-toolbar; + } +} + +[data-rich-text-script] { + display: inline; + + &::before { + content: "</>"; + background: rgb(255, 255, 0); + } +} diff --git a/gb-src/packages/block-editor/src/components/rich-text/toolbar-button.js b/gb-src/packages/block-editor/src/components/rich-text/toolbar-button.js new file mode 100644 index 0000000000000..2febd82e717c5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/rich-text/toolbar-button.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { Fill, ToolbarButton } from '@wordpress/components'; +import { displayShortcut } from '@wordpress/keycodes'; + +export function RichTextToolbarButton( { name, shortcutType, shortcutCharacter, ...props } ) { + let shortcut; + let fillName = 'RichText.ToolbarControls'; + + if ( name ) { + fillName += `.${ name }`; + } + + if ( shortcutType && shortcutCharacter ) { + shortcut = displayShortcut[ shortcutType ]( shortcutCharacter ); + } + + return ( + <Fill name={ fillName }> + <ToolbarButton + { ...props } + shortcut={ shortcut } + /> + </Fill> + ); +} diff --git a/gb-src/packages/block-editor/src/components/skip-to-selected-block/index.js b/gb-src/packages/block-editor/src/components/skip-to-selected-block/index.js new file mode 100644 index 0000000000000..672baa3920fd8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/skip-to-selected-block/index.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { getBlockFocusableWrapper } from '../../utils/dom'; + +const SkipToSelectedBlock = ( { selectedBlockClientId } ) => { + const onClick = () => { + const selectedBlockElement = getBlockFocusableWrapper( selectedBlockClientId ); + selectedBlockElement.focus(); + }; + + return ( + selectedBlockClientId && + <Button isDefault type="button" className="editor-skip-to-selected-block block-editor-skip-to-selected-block" onClick={ onClick }> + { __( 'Skip to the selected block' ) } + </Button> + ); +}; + +export default withSelect( ( select ) => { + return { + selectedBlockClientId: select( 'core/block-editor' ).getBlockSelectionStart(), + }; +} )( SkipToSelectedBlock ); diff --git a/gb-src/packages/block-editor/src/components/skip-to-selected-block/style.scss b/gb-src/packages/block-editor/src/components/skip-to-selected-block/style.scss new file mode 100644 index 0000000000000..a0c707df2167a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/skip-to-selected-block/style.scss @@ -0,0 +1,20 @@ +.block-editor-skip-to-selected-block { + position: absolute; + top: -9999em; + + &:focus { + height: auto; + width: auto; + display: block; + font-size: 14px; + font-weight: 600; + padding: 15px 23px 14px; + background: #f1f1f1; + color: theme(secondary); + line-height: normal; + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6); + text-decoration: none; + outline: none; + z-index: z-index(".skip-to-selected-block"); + } +} diff --git a/gb-src/packages/block-editor/src/components/typewriter/index.js b/gb-src/packages/block-editor/src/components/typewriter/index.js new file mode 100644 index 0000000000000..7600b2cd8747c --- /dev/null +++ b/gb-src/packages/block-editor/src/components/typewriter/index.js @@ -0,0 +1,254 @@ +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { computeCaretRect, getScrollContainer } from '@wordpress/dom'; +import { withSelect } from '@wordpress/data'; +import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; + +const isIE = window.navigator.userAgent.indexOf( 'Trident' ) !== -1; +const arrowKeyCodes = new Set( [ UP, DOWN, LEFT, RIGHT ] ); +const initialTriggerPercentage = 0.75; + +class Typewriter extends Component { + constructor() { + super( ...arguments ); + + this.ref = createRef(); + this.onKeyDown = this.onKeyDown.bind( this ); + this.addSelectionChangeListener = this.addSelectionChangeListener.bind( this ); + this.computeCaretRectOnSelectionChange = this.computeCaretRectOnSelectionChange.bind( this ); + this.maintainCaretPosition = this.maintainCaretPosition.bind( this ); + this.computeCaretRect = this.computeCaretRect.bind( this ); + this.onScrollResize = this.onScrollResize.bind( this ); + this.isSelectionEligibleForScroll = this.isSelectionEligibleForScroll.bind( this ); + } + + componentDidMount() { + // When the user scrolls or resizes, the scroll position should be + // reset. + window.addEventListener( 'scroll', this.onScrollResize, true ); + window.addEventListener( 'resize', this.onScrollResize, true ); + } + + componentWillUnmount() { + window.removeEventListener( 'scroll', this.onScrollResize, true ); + window.removeEventListener( 'resize', this.onScrollResize, true ); + document.removeEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + + if ( this.onScrollResize.rafId ) { + window.cancelAnimationFrame( this.onScrollResize.rafId ); + } + + if ( this.onKeyDown.rafId ) { + window.cancelAnimationFrame( this.onKeyDown.rafId ); + } + } + + /** + * Resets the scroll position to be maintained. + */ + computeCaretRect() { + if ( this.isSelectionEligibleForScroll() ) { + this.caretRect = computeCaretRect(); + } + } + + /** + * Resets the scroll position to be maintained during a `selectionchange` + * event. Also removes the listener, so it acts as a one-time listener. + */ + computeCaretRectOnSelectionChange() { + document.removeEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + this.computeCaretRect(); + } + + onScrollResize() { + if ( this.onScrollResize.rafId ) { + return; + } + + this.onScrollResize.rafId = window.requestAnimationFrame( () => { + this.computeCaretRect(); + delete this.onScrollResize.rafId; + } ); + } + + /** + * Checks if the current situation is elegible for scroll: + * - There should be one and only one block selected. + * - The component must contain the selection. + * - The active element must be contenteditable. + */ + isSelectionEligibleForScroll() { + return ( + this.props.selectedBlockClientId && + this.ref.current.contains( document.activeElement ) && + document.activeElement.isContentEditable + ); + } + + isLastEditableNode() { + const editableNodes = this.ref.current.querySelectorAll( + '[contenteditable="true"]' + ); + const lastEditableNode = editableNodes[ editableNodes.length - 1 ]; + return lastEditableNode === document.activeElement; + } + + /** + * Maintains the scroll position after a selection change caused by a + * keyboard event. + * + * @param {SyntheticEvent} event Synthetic keyboard event. + */ + maintainCaretPosition( { keyCode } ) { + if ( ! this.isSelectionEligibleForScroll() ) { + return; + } + + const currentCaretRect = computeCaretRect(); + + if ( ! currentCaretRect ) { + return; + } + + // If for some reason there is no position set to be scrolled to, let + // this be the position to be scrolled to in the future. + if ( ! this.caretRect ) { + this.caretRect = currentCaretRect; + return; + } + + // Even though enabling the typewriter effect for arrow keys results in + // a pleasant experience, it may not be the case for everyone, so, for + // now, let's disable it. + if ( arrowKeyCodes.has( keyCode ) ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + const diff = currentCaretRect.top - this.caretRect.top; + + if ( diff === 0 ) { + return; + } + + const scrollContainer = getScrollContainer( this.ref.current ); + + // The page must be scrollable. + if ( ! scrollContainer ) { + return; + } + + const windowScroll = scrollContainer === document.body; + const scrollY = windowScroll ? + window.scrollY : + scrollContainer.scrollTop; + const scrollContainerY = windowScroll ? + 0 : + scrollContainer.getBoundingClientRect().top; + const relativeScrollPosition = windowScroll ? + this.caretRect.top / window.innerHeight : + ( this.caretRect.top - scrollContainerY ) / + ( window.innerHeight - scrollContainerY ); + + // If the scroll position is at the start, the active editable element + // is the last one, and the caret is positioned within the initial + // trigger percentage of the page, do not scroll the page. + // The typewriter effect should not kick in until an empty page has been + // filled with the initial trigger percentage or the user scrolls + // intentionally down. + if ( + scrollY === 0 && + relativeScrollPosition < initialTriggerPercentage && + this.isLastEditableNode() + ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + const scrollContainerHeight = windowScroll ? + window.innerHeight : + scrollContainer.clientHeight; + + // Abort if the target scroll position would scroll the caret out of + // view. + if ( + // The caret is under the lower fold. + this.caretRect.top + this.caretRect.height > + scrollContainerY + scrollContainerHeight || + // The caret is above the upper fold. + this.caretRect.top < scrollContainerY + ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + if ( windowScroll ) { + window.scrollBy( 0, diff ); + } else { + scrollContainer.scrollTop += diff; + } + } + + /** + * Adds a `selectionchange` listener to reset the scroll position to be + * maintained. + */ + addSelectionChangeListener() { + document.addEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + } + + onKeyDown( event ) { + event.persist(); + + // Ensure the any remaining request is cancelled. + if ( this.onKeyDown.rafId ) { + window.cancelAnimationFrame( this.onKeyDown.rafId ); + } + + // Use an animation frame for a smooth result. + this.onKeyDown.rafId = window.requestAnimationFrame( () => { + this.maintainCaretPosition( event ); + delete this.onKeyDown.rafId; + } ); + } + + render() { + // There are some issues with Internet Explorer, which are probably not + // worth spending time on. Let's disable it. + if ( isIE ) { + return this.props.children; + } + + // Disable reason: Wrapper itself is non-interactive, but must capture + // bubbling events from children to determine focus transition intents. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + ref={ this.ref } + onKeyDown={ this.onKeyDown } + onKeyUp={ this.maintainCaretPosition } + onMouseDown={ this.addSelectionChangeListener } + onTouchStart={ this.addSelectionChangeListener } + > + { this.props.children } + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +/** + * Ensures that the text selection keeps the same vertical distance from the + * viewport during keyboard events within this component. The vertical distance + * can vary. It is the last clicked or scrolled to position. + */ +export default withSelect( ( select ) => { + const { getSelectedBlockClientId } = select( 'core/block-editor' ); + return { selectedBlockClientId: getSelectedBlockClientId() }; +} )( Typewriter ); diff --git a/gb-src/packages/block-editor/src/components/url-input/README.md b/gb-src/packages/block-editor/src/components/url-input/README.md new file mode 100644 index 0000000000000..d2d11f7573b47 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/README.md @@ -0,0 +1,212 @@ +# `URLInputButton` + +Render a URL input button that pops up an input to search for and select a post or enter any arbitrary URL. + +## Properties + +### `url: String` + +*Required.* This should be set to the attribute (or component state) property used to store the URL. + +### `onChange( url: String, ?post: Object ): Function` + +*Required.* Called when the value changes. The second parameter is `null` unless the user selects a post from the suggestions dropdown. In those cases the `post` parameter will look like this: + +```json +{ + "id": 1, + "subtype": "page", + "title": "Sample Page", + "type": "post", + "url": "https://example.com/sample-page/", + "_links": { + "self": [ { "embeddable": true, "href": "https://example.com/wp-json/wp/v2/pages/1" } ], + "about": [ { "href": "https://example.com/wp-json/wp/v2/types/page" } ], + "collection": [ { "href": "https://example.com/wp-json/wp/v2/search" } ] + } +} +``` + +This prop is passed directly to the `URLInput` component. + +## Example + +{% codetabs %} +{% ES5 %} +```js +wp.blocks.registerBlockType( /* ... */, { + // ... + + attributes: { + url: { + type: 'string' + }, + text: { + type: 'string' + } + }, + + edit: function( props ) { + return wp.element.createElement( wp.editor.URLInputButton, { + className: props.className, + url: props.attributes.url, + onChange: function( url, post ) { + props.setAttributes( { url: url, text: (post && post.title) || 'Click here' } ); + } + } ); + }, + + save: function( props ) { + return wp.element.createElement( 'a', { + href: props.attributes.url, + }, props.attributes.text ); + } +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { URLInputButton } = wp.editor; + +registerBlockType( /* ... */, { + // ... + + attributes: { + url: { + type: 'string', + }, + text: { + type: 'string', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + <URLInputButton + url={ attributes.url } + onChange={ ( url, post ) => setAttributes( { url, text: (post && post.title) || 'Click here' } ) } + /> + ); + }, + + save( { attributes } ) { + return <a href={ attributes.url }>{ attributes.text }</a>; + } +} ); +``` +{% end %} + +# `URLInput` + +Renders the URL input field used by the `URLInputButton` component. It can be used directly to display the input field in different ways such as in a `Popover` or inline. + +## Properties + +### `value: String` + +*Required.* This should be set to the attribute (or component state) property used to store the URL. + +### `onChange( url: String, ?post: Object ): Function` + +*Required.* Called when the value changes. The second parameter is `null` unless the user selects a post from the suggestions dropdown. In those cases the `post` parameter will look like this: + +```json +{ + "id": 1, + "subtype": "page", + "title": "Sample Page", + "type": "post", + "url": "https://example.com/sample-page/", + "_links": { + "self": [ { "embeddable": true, "href": "https://example.com/wp-json/wp/v2/pages/1" } ], + "about": [ { "href": "https://example.com/wp-json/wp/v2/types/page" } ], + "collection": [ { "href": "https://example.com/wp-json/wp/v2/search" } ] + } +} +``` + +### `autoFocus: Boolean` + +*Optional.* By default, the input will gain focus when it is rendered, as typically it is displayed conditionally. For example when clicking on `URLInputButton` or editing a block. + +If you are not conditionally rendering this component set this property to `false`. + +### `className: String` + +*Optional.* Adds and optional class to the parent `div` that wraps the URLInput field and popover + +### `disableSuggestions: Boolean` + +*Optional.* Provides additional control over whether suggestions are disabled. + +When hiding the URLInput using CSS (as is sometimes done for accessibility purposes), the suggestions can still be displayed. This is because they're rendered in a popover in a different part of the DOM, so any styles applied to the URLInput's container won't affect the popover. + +This prop allows the suggestions list to be programmatically not rendered by passing a boolean—it can be `true` to make sure suggestions aren't rendered, or `false`/`undefined` to fall back to the default behaviour of showing suggestions when matching autocompletion items are found. + +## Example + +{% codetabs %} +{% ES5 %} +```js +wp.blocks.registerBlockType( /* ... */, { + // ... + + attributes: { + url: { + type: 'string' + }, + text: { + type: 'string' + } + }, + + edit: function( props ) { + return wp.element.createElement( wp.editor.URLInput, { + className: props.className, + value: props.attributes.url, + onChange: function( url, post ) { + props.setAttributes( { url: url, text: (post && post.title) || 'Click here' } ); + } + } ); + }, + + save: function( props ) { + return wp.element.createElement( 'a', { + href: props.attributes.url, + }, props.attributes.text ); + } +} ); +``` +{% ESNext %} +```js +const { registerBlockType } = wp.blocks; +const { URLInput } = wp.editor; + +registerBlockType( /* ... */, { + // ... + + attributes: { + url: { + type: 'string', + }, + text: { + type: 'string', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + <URLInput + className={ className } + value={ attributes.url } + onChange={ ( url, post ) => setAttributes( { url, text: (post && post.title) || 'Click here' } ) } + /> + ); + }, + + save( { attributes } ) { + return <a href={ attributes.url }>{ attributes.text }</a>; + } +} ); +``` +{% end %} diff --git a/gb-src/packages/block-editor/src/components/url-input/button.js b/gb-src/packages/block-editor/src/components/url-input/button.js new file mode 100644 index 0000000000000..077decf42d631 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/button.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { IconButton } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import URLInput from './'; + +class URLInputButton extends Component { + constructor() { + super( ...arguments ); + this.toggle = this.toggle.bind( this ); + this.submitLink = this.submitLink.bind( this ); + this.state = { + expanded: false, + }; + } + + toggle() { + this.setState( { expanded: ! this.state.expanded } ); + } + + submitLink( event ) { + event.preventDefault(); + this.toggle(); + } + + render() { + const { url, onChange } = this.props; + const { expanded } = this.state; + const buttonLabel = url ? __( 'Edit link' ) : __( 'Insert link' ); + + return ( + <div className="editor-url-input__button block-editor-url-input__button"> + <IconButton + icon="admin-links" + label={ buttonLabel } + onClick={ this.toggle } + className={ classnames( 'components-toolbar__control', { + 'is-active': url, + } ) } + /> + { expanded && + <form + className="editor-url-input__button-modal block-editor-url-input__button-modal" + onSubmit={ this.submitLink } + > + <div className="editor-url-input__button-modal-line block-editor-url-input__button-modal-line"> + <IconButton + className="editor-url-input__back block-editor-url-input__back" + icon="arrow-left-alt" + label={ __( 'Close' ) } + onClick={ this.toggle } + /> + <URLInput value={ url || '' } onChange={ onChange } /> + <IconButton + icon="editor-break" + label={ __( 'Submit' ) } + type="submit" + /> + </div> + </form> + } + </div> + ); + } +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-input/README.md + */ +export default URLInputButton; diff --git a/gb-src/packages/block-editor/src/components/url-input/index.js b/gb-src/packages/block-editor/src/components/url-input/index.js new file mode 100644 index 0000000000000..9652df56c9da7 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/index.js @@ -0,0 +1,320 @@ +/** + * External dependencies + */ +import { throttle } from 'lodash'; +import classnames from 'classnames'; +import scrollIntoView from 'dom-scroll-into-view'; + +/** + * WordPress dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { UP, DOWN, ENTER, TAB } from '@wordpress/keycodes'; +import { Spinner, withSpokenMessages, Popover } from '@wordpress/components'; +import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +// Since URLInput is rendered in the context of other inputs, but should be +// considered a separate modal node, prevent keyboard events from propagating +// as being considered from the input. +const stopEventPropagation = ( event ) => event.stopPropagation(); + +class URLInput extends Component { + constructor( { autocompleteRef } ) { + super( ...arguments ); + + this.onChange = this.onChange.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.autocompleteRef = autocompleteRef || createRef(); + this.inputRef = createRef(); + this.updateSuggestions = throttle( this.updateSuggestions.bind( this ), 200 ); + + this.suggestionNodes = []; + + this.state = { + suggestions: [], + showSuggestions: false, + selectedSuggestion: null, + }; + } + + componentDidUpdate() { + const { showSuggestions, selectedSuggestion } = this.state; + // only have to worry about scrolling selected suggestion into view + // when already expanded + if ( showSuggestions && selectedSuggestion !== null && ! this.scrollingIntoView ) { + this.scrollingIntoView = true; + scrollIntoView( this.suggestionNodes[ selectedSuggestion ], this.autocompleteRef.current, { + onlyScrollIfNeeded: true, + } ); + + this.props.setTimeout( () => { + this.scrollingIntoView = false; + }, 100 ); + } + } + + componentWillUnmount() { + delete this.suggestionsRequest; + } + + bindSuggestionNode( index ) { + return ( ref ) => { + this.suggestionNodes[ index ] = ref; + }; + } + + updateSuggestions( value ) { + const { fetchLinkSuggestions } = this.props; + if ( ! fetchLinkSuggestions ) { + return; + } + + // Show the suggestions after typing at least 2 characters + // and also for URLs + if ( value.length < 2 || /^https?:/.test( value ) ) { + this.setState( { + showSuggestions: false, + selectedSuggestion: null, + loading: false, + } ); + + return; + } + + this.setState( { + showSuggestions: true, + selectedSuggestion: null, + loading: true, + } ); + + const request = fetchLinkSuggestions( value ); + + request.then( ( suggestions ) => { + // A fetch Promise doesn't have an abort option. It's mimicked by + // comparing the request reference in on the instance, which is + // reset or deleted on subsequent requests or unmounting. + if ( this.suggestionsRequest !== request ) { + return; + } + + this.setState( { + suggestions, + loading: false, + } ); + + if ( !! suggestions.length ) { + this.props.debouncedSpeak( sprintf( _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + suggestions.length + ), suggestions.length ), 'assertive' ); + } else { + this.props.debouncedSpeak( __( 'No results.' ), 'assertive' ); + } + } ).catch( () => { + if ( this.suggestionsRequest === request ) { + this.setState( { + loading: false, + } ); + } + } ); + + this.suggestionsRequest = request; + } + + onChange( event ) { + const inputValue = event.target.value; + this.props.onChange( inputValue ); + this.updateSuggestions( inputValue ); + } + + onKeyDown( event ) { + const { showSuggestions, selectedSuggestion, suggestions, loading } = this.state; + // If the suggestions are not shown or loading, we shouldn't handle the arrow keys + // We shouldn't preventDefault to allow block arrow keys navigation + if ( ! showSuggestions || ! suggestions.length || loading ) { + // In the Windows version of Firefox the up and down arrows don't move the caret + // within an input field like they do for Mac Firefox/Chrome/Safari. This causes + // a form of focus trapping that is disruptive to the user experience. This disruption + // only happens if the caret is not in the first or last position in the text input. + // See: https://github.com/WordPress/gutenberg/issues/5693#issuecomment-436684747 + switch ( event.keyCode ) { + // When UP is pressed, if the caret is at the start of the text, move it to the 0 + // position. + case UP: { + if ( 0 !== event.target.selectionStart ) { + event.stopPropagation(); + event.preventDefault(); + + // Set the input caret to position 0 + event.target.setSelectionRange( 0, 0 ); + } + break; + } + // When DOWN is pressed, if the caret is not at the end of the text, move it to the + // last position. + case DOWN: { + if ( this.props.value.length !== event.target.selectionStart ) { + event.stopPropagation(); + event.preventDefault(); + + // Set the input caret to the last position + event.target.setSelectionRange( this.props.value.length, this.props.value.length ); + } + break; + } + } + + return; + } + + const suggestion = this.state.suggestions[ this.state.selectedSuggestion ]; + + switch ( event.keyCode ) { + case UP: { + event.stopPropagation(); + event.preventDefault(); + const previousIndex = ! selectedSuggestion ? suggestions.length - 1 : selectedSuggestion - 1; + this.setState( { + selectedSuggestion: previousIndex, + } ); + break; + } + case DOWN: { + event.stopPropagation(); + event.preventDefault(); + const nextIndex = selectedSuggestion === null || ( selectedSuggestion === suggestions.length - 1 ) ? 0 : selectedSuggestion + 1; + this.setState( { + selectedSuggestion: nextIndex, + } ); + break; + } + case TAB: { + if ( this.state.selectedSuggestion !== null ) { + this.selectLink( suggestion ); + // Announce a link has been selected when tabbing away from the input field. + this.props.speak( __( 'Link selected.' ) ); + } + break; + } + case ENTER: { + if ( this.state.selectedSuggestion !== null ) { + event.stopPropagation(); + this.selectLink( suggestion ); + } + break; + } + } + } + + selectLink( suggestion ) { + this.props.onChange( suggestion.url, suggestion ); + this.setState( { + selectedSuggestion: null, + showSuggestions: false, + } ); + } + + handleOnClick( suggestion ) { + this.selectLink( suggestion ); + // Move focus to the input field when a link suggestion is clicked. + this.inputRef.current.focus(); + } + + static getDerivedStateFromProps( { disableSuggestions }, { showSuggestions } ) { + return { + showSuggestions: disableSuggestions === true ? false : showSuggestions, + }; + } + + render() { + const { value = '', autoFocus = true, instanceId, className, id, isFullWidth, hasBorder } = this.props; + const { showSuggestions, suggestions, selectedSuggestion, loading } = this.state; + + const suggestionsListboxId = `block-editor-url-input-suggestions-${ instanceId }`; + const suggestionOptionIdPrefix = `block-editor-url-input-suggestion-${ instanceId }`; + + /* eslint-disable jsx-a11y/no-autofocus */ + return ( + <div className={ classnames( 'editor-url-input block-editor-url-input', className, { + 'is-full-width': isFullWidth, + 'has-border': hasBorder, + } ) }> + <input + id={ id } + autoFocus={ autoFocus } + type="text" + aria-label={ __( 'URL' ) } + required + value={ value } + onChange={ this.onChange } + onInput={ stopEventPropagation } + placeholder={ __( 'Paste URL or type to search' ) } + onKeyDown={ this.onKeyDown } + role="combobox" + aria-expanded={ showSuggestions } + aria-autocomplete="list" + aria-owns={ suggestionsListboxId } + aria-activedescendant={ selectedSuggestion !== null ? `${ suggestionOptionIdPrefix }-${ selectedSuggestion }` : undefined } + ref={ this.inputRef } + /> + + { ( loading ) && <Spinner /> } + + { showSuggestions && !! suggestions.length && + <Popover + position="bottom" + noArrow + focusOnMount={ false } + > + <div + className={ classnames( + 'editor-url-input__suggestions', + 'block-editor-url-input__suggestions', + `${ className }__suggestions` + ) } + id={ suggestionsListboxId } + ref={ this.autocompleteRef } + role="listbox" + > + { suggestions.map( ( suggestion, index ) => ( + <button + key={ suggestion.id } + role="option" + tabIndex="-1" + id={ `${ suggestionOptionIdPrefix }-${ index }` } + ref={ this.bindSuggestionNode( index ) } + className={ classnames( 'editor-url-input__suggestion block-editor-url-input__suggestion', { + 'is-selected': index === selectedSuggestion, + } ) } + onClick={ () => this.handleOnClick( suggestion ) } + aria-selected={ index === selectedSuggestion } + > + { suggestion.title } + </button> + ) ) } + </div> + </Popover> + } + </div> + ); + /* eslint-enable jsx-a11y/no-autofocus */ + } +} + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-input/README.md + */ +export default compose( + withSafeTimeout, + withSpokenMessages, + withInstanceId, + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + return { + fetchLinkSuggestions: getSettings().__experimentalFetchLinkSuggestions, + }; + } ) +)( URLInput ); diff --git a/gb-src/packages/block-editor/src/components/url-input/index.native.js b/gb-src/packages/block-editor/src/components/url-input/index.native.js new file mode 100644 index 0000000000000..a76252cadff39 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/index.native.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { TextInput } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; + +class URLInput extends Component { + render() { + const { value = '', autoFocus = true, ...extraProps } = this.props; + /* eslint-disable jsx-a11y/no-autofocus */ + return ( + <TextInput + autoFocus={ autoFocus } + editable + selectTextOnFocus + autoCapitalize="none" + autoCorrect={ false } + textContentType="URL" + value={ value } + onChangeText={ this.props.onChange } + placeholder={ __( 'Paste URL' ) } + { ...extraProps } + /> + ); + /* eslint-enable jsx-a11y/no-autofocus */ + } +} + +export default withInstanceId( URLInput ); diff --git a/gb-src/packages/block-editor/src/components/url-input/style.scss b/gb-src/packages/block-editor/src/components/url-input/style.scss new file mode 100644 index 0000000000000..5e6f34762a460 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/style.scss @@ -0,0 +1,142 @@ +// Link input +$input-padding: 8px; +$input-size: 300px; + +.block-editor-block-list__block .block-editor-url-input, +.components-popover .block-editor-url-input, +.block-editor-url-input { + flex-grow: 1; + position: relative; + padding: 1px; + + input[type="text"] { + width: 100%; + @include break-small() { + width: $input-size; + } + padding: $input-padding; + border: none; + border-radius: 0; + margin-left: 0; + margin-right: 0; + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + @include break-small { + font-size: $default-font-size; + } + + &::-ms-clear { + display: none; + } + } + + &.has-border input[type="text"] { + border: 1px solid $dark-gray-500; + border-radius: 4px; + } + + &.is-full-width { + width: 100%; + + input[type="text"] { + width: 100%; + } + + &__suggestions { + width: 100%; + } + } + + .components-spinner { + position: absolute; + right: $input-padding; + top: $input-padding + 1; + margin: 0; + } +} + +// Suggestions +.block-editor-url-input__suggestions { + max-height: 200px; + transition: all 0.15s ease-in-out; + @include reduce-motion("transition"); + padding: 4px 0; + // To match the url-input width: input width + padding + 2 buttons. + width: $input-size + 2; + overflow-y: auto; +} + +// Hide suggestions on mobile until we @todo find a better way to show them +.block-editor-url-input__suggestions, +.block-editor-url-input .components-spinner { + display: none; + @include break-small() { + display: inherit; + } +} + +.block-editor-url-input__suggestion { + padding: 4px $input-padding; + color: $dark-gray-300; // lightest we can use for contrast + display: block; + font-size: $default-font-size; + cursor: pointer; + background: $white; + width: 100%; + border: none; + text-align: left; + @include menu-style__neutral(); + + &:hover { + background: $light-gray-500; + } + + &:focus, + &.is-selected { + background: color(theme(primary) shade(15%)); + color: $white; + outline: none; + } +} + +// Toolbar button +.components-toolbar > .block-editor-url-input__button { + position: inherit; // Let the dialog position according to parent. +} + +.block-editor-url-input__button .block-editor-url-input__back { + margin-right: 4px; + overflow: visible; + + &::after { + content: ""; + position: absolute; + display: block; + width: 1px; + height: 24px; + right: -1px; + background: $light-gray-500; + } +} + +.block-editor-url-input__button-modal { + box-shadow: $shadow-popover; + border: 1px solid $light-gray-500; + background: $white; +} + +.block-editor-url-input__button-modal-line { + display: flex; + flex-direction: row; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + align-items: flex-start; + + .components-button { + flex-shrink: 0; + width: $icon-button-size; + height: $icon-button-size; + } +} diff --git a/gb-src/packages/block-editor/src/components/url-input/test/button.js b/gb-src/packages/block-editor/src/components/url-input/test/button.js new file mode 100644 index 0000000000000..427ea0ca8531e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-input/test/button.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import TestUtils from 'react-dom/test-utils'; +import ReactDOM from 'react-dom'; + +/** + * Internal dependencies + */ +import URLInput from '../'; +import URLInputButton from '../button'; + +import '../../../store'; + +describe( 'URLInputButton', () => { + const clickEditLink = ( wrapper ) => wrapper.find( 'ForwardRef(IconButton).components-toolbar__control' ).simulate( 'click' ); + + it( 'should have a valid class name in the wrapper tag', () => { + const wrapper = shallow( <URLInputButton /> ); + expect( wrapper.hasClass( 'block-editor-url-input__button' ) ).toBe( true ); + } ); + it( 'should not have is-active class when url prop not defined', () => { + const wrapper = shallow( <URLInputButton /> ); + expect( wrapper.find( 'ForwardRef(IconButton)' ).hasClass( 'is-active' ) ).toBe( false ); + } ); + it( 'should have is-active class name if url prop defined', () => { + const wrapper = shallow( <URLInputButton url="https://example.com" /> ); + expect( wrapper.find( 'ForwardRef(IconButton)' ).hasClass( 'is-active' ) ).toBe( true ); + } ); + it( 'should have hidden form by default', () => { + const wrapper = shallow( <URLInputButton /> ); + expect( wrapper.find( 'form' ) ).toHaveLength( 0 ); + expect( wrapper.state().expanded ).toBe( false ); + } ); + it( 'should have visible form when Edit Link button clicked', () => { + const wrapper = shallow( <URLInputButton /> ); + clickEditLink( wrapper ); + expect( wrapper.find( 'form' ) ).toHaveLength( 1 ); + expect( wrapper.state().expanded ).toBe( true ); + } ); + it( 'should call onChange function once when value changes once', () => { + const onChangeMock = jest.fn(); + const wrapper = shallow( <URLInputButton onChange={ onChangeMock } /> ); + clickEditLink( wrapper ); + wrapper.find( URLInput ).simulate( 'change' ); + expect( onChangeMock ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'should call onChange function twice when value changes twice', () => { + const onChangeMock = jest.fn(); + const wrapper = shallow( <URLInputButton onChange={ onChangeMock } /> ); + clickEditLink( wrapper ); + wrapper.find( URLInput ).simulate( 'change' ); + wrapper.find( URLInput ).simulate( 'change' ); + expect( onChangeMock ).toHaveBeenCalledTimes( 2 ); + } ); + it( 'should close the form when user clicks Close button', () => { + const wrapper = shallow( <URLInputButton /> ); + clickEditLink( wrapper ); + expect( wrapper.state().expanded ).toBe( true ); + wrapper.find( '.block-editor-url-input__back' ).simulate( 'click' ); + expect( wrapper.state().expanded ).toBe( false ); + } ); + it( 'should close the form when user submits it', () => { + const wrapper = TestUtils.renderIntoDocument( <URLInputButton /> ); + const buttonElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-toolbar__control' + ); + const formElement = () => TestUtils.findRenderedDOMComponentWithTag( + wrapper, + 'form' + ); + TestUtils.Simulate.click( buttonElement() ); + expect( wrapper.state.expanded ).toBe( true ); + TestUtils.Simulate.submit( formElement() ); + expect( wrapper.state.expanded ).toBe( false ); + // eslint-disable-next-line react/no-find-dom-node + ReactDOM.unmountComponentAtNode( ReactDOM.findDOMNode( wrapper ).parentNode ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/url-popover/README.md b/gb-src/packages/block-editor/src/components/url-popover/README.md new file mode 100644 index 0000000000000..86805b10229e8 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/README.md @@ -0,0 +1,190 @@ +URLPopover +=========== + +URLPopover is a presentational React component used to render a popover used for editing and viewing a url. + +## Setup + +The component will be rendered adjacent to its parent. + +```jsx +import { ToggleControl, IconButton, Button } from '@wordpress/components'; +import { URLPopover } from '@wordpress/block-editor'; + +class MyURLPopover extends Component { + constructor() { + super( ...arguments ); + + this.onChangeURL = this.onChangeURL.bind( this ); + this.openURLPopover = this.closeURLPopover.bind( this ); + this.closeURLPopover = this.closeURLPopover.bind( this ); + this.submitURL = this.submitURL.bind( this ); + this.setTarget = this.setTarget.bind( this ); + + this.state = { + isVisible: false, + }; + } + + onChangeURL( url ) { + this.setState( { url } ); + } + + openURLPopover() { + this.setState( { + isVisible: true, + } ); + } + + closeURLPopover() { + this.setState( { + isVisible: false, + } ); + } + + submitURL() { + // Not shown: Store the updated url. + + this.closeURLPopover(); + } + + setTarget() { + // Not shown: Store the updated 'opensInNewWindow' setting. + } + + render() { + const { opensInNewWindow } = this.props; + const { url, isVisible, isEditing } = this.state; + + return ( + <> + <Button onClick={ this.openURLPopover }>Edit URL</Button> + { isVisible && ( + <URLPopover + onClose={ this.closeURLPopover } + renderSettings={ () => ( + <ToggleControl + label={ __( 'Open in New Tab' ) } + checked={ opensInNewWindow } + onChange={ this.setTarget } + /> + ) } + > + <form onSubmit={ this.submitURL }> + <input type="url" value={ url } onChange={ this.onChangeURL } /> + <IconButton icon="editor-break" label={ __( 'Apply' ) } type="submit" /> + </form> + </URLPopover> + ) } + </> + ); + } +} +``` + +## Props + +The component accepts the following props. Any other props are passed through to the underlying `Popover` component ([refer to props documentation](/packages/components/src/popover/README.md)). + +### position + +Where the Popover should be positioned relative to its parent. Defaults to "bottom center". + +- Type: `String` +- Required: No +- Default: "bottom center" + +### focusOnMount + +Provides control over which element is focused when the URLPopover mounts. Pass `false` to avoid focusing +an element. + +- Type: `String` +- Required: No +- Default: "firstElement" + +### renderSettings + +Callback used to return the React Elements that will be rendered inside the settings drawer. When this function +is provided, a toggle button will be rendered in the popover that allows the user to open and close the settings +drawer. + +- Type: `Function` +- Required: No + + +## Useful UI pieces + +The URLPopover exposes two components that may be used as child components to make the UI creation process easier. +Although in the editor these components are used inside URLPopover and they were built with URLPopover use cases in mind, it maybe is possible and perfectly fine to use them standalone if they fit a use-case. + +### LinkViewer + +LinkViewer provides a simple UI that allows users to see a link and may also offer a button to switch to a mode that will enable editing that link. +The component accepts the following props. Any other props are passed through to the underlying `div` container. + +### className + +A class that together with "block-editor-url-popover__link-viewer" is used as a class of the wrapper div. +If no className is passed only "block-editor-url-popover__link-viewer" is used. + +- Type: `String` +- Required: No + +### linkClassName + +A class that will be used in the component that renders the link. + +- Type: `String` +- Required: No + +### url + +The current URL to view. + +- Type: `String` +- Required: Yes + +### urlLabel + +The URL label, if not passed a label is automatically generated from the `url`. + +- Type: `String` +- Required: No + +### onEditLinkClick + +A function called when the user presses the button that allows editing a link. If not passed the link-edit button is not rendered. + +- Type: `function` +- Required: No + + +### LinkEditor + +LinkEditor provides a simple UI that allows users to edit link. +The component accepts the following props. Any other props are passed through to the underlying `form` container. + +### value + +This property should be set to the attribute (or component state) property used to store the URL. +This property is directly passed to `URLInput` component ([refer to its documentation](/packages/components/src/url-input/README.md)) to read additional details. + +- Type: `String` +- Required: Yes + +### onChange + +Called when the value changes. The second parameter is `null` unless the user selects a post from the suggestions dropdown. +More +This property is directly passed to component `URLInput` ([refer to its documentation](/packages/components/src/url-input/README.md)) to read additional details. + +- Type: `function` +- Required: Yes + +### autocompleteRef + +Reference passed to the auto complete element of the ([URLInput component](/packages/components/src/url-input/README.md)). + +- Type: `Object` +- Required: no diff --git a/gb-src/packages/block-editor/src/components/url-popover/index.js b/gb-src/packages/block-editor/src/components/url-popover/index.js new file mode 100644 index 0000000000000..e39e4cc478e3e --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/index.js @@ -0,0 +1,95 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { + IconButton, + Popover, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import LinkViewer from './link-viewer'; +import LinkEditor from './link-editor'; + +class URLPopover extends Component { + constructor() { + super( ...arguments ); + + this.toggleSettingsVisibility = this.toggleSettingsVisibility.bind( this ); + + this.state = { + isSettingsExpanded: false, + }; + } + + toggleSettingsVisibility() { + this.setState( { + isSettingsExpanded: ! this.state.isSettingsExpanded, + } ); + } + + render() { + const { + additionalControls, + children, + renderSettings, + position = 'bottom center', + focusOnMount = 'firstElement', + ...popoverProps + } = this.props; + + const { + isSettingsExpanded, + } = this.state; + + const showSettings = !! renderSettings && isSettingsExpanded; + + return ( + <Popover + className="editor-url-popover block-editor-url-popover" + focusOnMount={ focusOnMount } + position={ position } + { ...popoverProps } + > + <div className="block-editor-url-popover__input-container"> + <div className="editor-url-popover__row block-editor-url-popover__row"> + { children } + { !! renderSettings && ( + <IconButton + className="editor-url-popover__settings-toggle block-editor-url-popover__settings-toggle" + icon="arrow-down-alt2" + label={ __( 'Link settings' ) } + onClick={ this.toggleSettingsVisibility } + aria-expanded={ isSettingsExpanded } + /> + ) } + </div> + { showSettings && ( + <div className="editor-url-popover__row block-editor-url-popover__row editor-url-popover__settings block-editor-url-popover__settings"> + { renderSettings() } + </div> + ) } + </div> + { additionalControls && ! showSettings && ( + <div + className="block-editor-url-popover__additional-controls" + > + { additionalControls } + </div> + ) } + </Popover> + ); + } +} + +URLPopover.LinkEditor = LinkEditor; + +URLPopover.LinkViewer = LinkViewer; + +/** + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/url-popover/README.md + */ +export default URLPopover; diff --git a/gb-src/packages/block-editor/src/components/url-popover/link-editor.js b/gb-src/packages/block-editor/src/components/url-popover/link-editor.js new file mode 100644 index 0000000000000..ebb96e00a302a --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/link-editor.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + IconButton, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import URLInput from '../url-input'; + +export default function LinkEditor( { + autocompleteRef, + className, + onChangeInputValue, + value, + ...props +} ) { + return ( + <form + className={ classnames( + 'block-editor-url-popover__link-editor', + className + ) } + { ...props } + > + <URLInput + value={ value } + onChange={ onChangeInputValue } + autocompleteRef={ autocompleteRef } + /> + <IconButton icon="editor-break" label={ __( 'Apply' ) } type="submit" /> + </form> + ); +} diff --git a/gb-src/packages/block-editor/src/components/url-popover/link-viewer.js b/gb-src/packages/block-editor/src/components/url-popover/link-viewer.js new file mode 100644 index 0000000000000..3447cb3dfcf15 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/link-viewer.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + ExternalLink, + IconButton, +} from '@wordpress/components'; +import { safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; + +function LinkViewerUrl( { url, urlLabel, className } ) { + const linkClassName = classnames( + className, + 'block-editor-url-popover__link-viewer-url' + ); + + if ( ! url ) { + return <span className={ linkClassName }></span>; + } + + return ( + <ExternalLink + className={ linkClassName } + href={ url } + > + { urlLabel || filterURLForDisplay( safeDecodeURI( url ) ) } + </ExternalLink> + ); +} + +export default function LinkViewer( { + className, + linkClassName, + onEditLinkClick, + url, + urlLabel, + ...props +} ) { + return ( + <div + className={ classnames( + 'block-editor-url-popover__link-viewer', + className + ) } + { ...props } + > + <LinkViewerUrl url={ url } urlLabel={ urlLabel } className={ linkClassName } /> + { onEditLinkClick && <IconButton icon="edit" label={ __( 'Edit' ) } onClick={ onEditLinkClick } /> } + </div> + ); +} diff --git a/gb-src/packages/block-editor/src/components/url-popover/style.scss b/gb-src/packages/block-editor/src/components/url-popover/style.scss new file mode 100644 index 0000000000000..268908dd01478 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/style.scss @@ -0,0 +1,93 @@ +.block-editor-url-popover__additional-controls { + border-top: $border-width solid $light-gray-500; +} + +.block-editor-url-popover__additional-controls > div[role="menu"] .components-icon-button:not(:disabled):not([aria-disabled="true"]):not(.is-default) > svg { + box-shadow: none; +} + +.block-editor-url-popover__additional-controls div[role="menu"] > .components-icon-button { + padding-left: 2px; +} + +.block-editor-url-popover__row { + display: flex; +} + +// Any children of the popover-row that are not the settings-toggle +// should take up as much space as possible. +.block-editor-url-popover__row > :not(.block-editor-url-popover__settings-toggle) { + flex-grow: 1; +} + +// Mimic toolbar component styles for the icons in this popover. +.block-editor-url-popover .components-icon-button { + padding: 3px; + + > svg { + padding: 5px; + border-radius: $radius-round-rectangle; + height: 30px; + width: 30px; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + + > svg { + @include formatting-button-style__hover; + } + } + + &:not(:disabled):focus { + box-shadow: none; + + > svg { + @include formatting-button-style__focus; + } + } +} + +.block-editor-url-popover__settings-toggle { + flex-shrink: 0; + + // Add a left divider to the toggle button. + border-radius: 0; + border-left: $border-width solid $light-gray-500; + margin-left: 1px; + + &[aria-expanded="true"] .dashicon { + transform: rotate(180deg); + } +} + +.block-editor-url-popover__settings { + display: block; + padding: $panel-padding; + border-top: $border-width solid $light-gray-500; + + .components-base-control:last-child, + .components-base-control:last-child .components-base-control__field { + margin-bottom: 0; + } +} + +.block-editor-url-popover__link-editor, +.block-editor-url-popover__link-viewer { + display: flex; +} + +.block-editor-url-popover__link-viewer-url { + margin: $grid-size - $border-width; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 150px; + max-width: 500px; + + &.has-invalid-link { + color: $alert-red; + } +} diff --git a/gb-src/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..c6d7ebe5091ef --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`URLPopover matches the snapshot in its default state 1`] = ` +<Popover + className="editor-url-popover block-editor-url-popover" + focusOnMount="firstElement" + position="bottom center" +> + <div + className="block-editor-url-popover__input-container" + > + <div + className="editor-url-popover__row block-editor-url-popover__row" + > + <div> + Editor + </div> + <ForwardRef(IconButton) + aria-expanded={false} + className="editor-url-popover__settings-toggle block-editor-url-popover__settings-toggle" + icon="arrow-down-alt2" + label="Link settings" + onClick={[Function]} + /> + </div> + </div> +</Popover> +`; + +exports[`URLPopover matches the snapshot when the settings are toggled open 1`] = ` +<Popover + className="editor-url-popover block-editor-url-popover" + focusOnMount="firstElement" + position="bottom center" +> + <div + className="block-editor-url-popover__input-container" + > + <div + className="editor-url-popover__row block-editor-url-popover__row" + > + <div> + Editor + </div> + <ForwardRef(IconButton) + aria-expanded={true} + className="editor-url-popover__settings-toggle block-editor-url-popover__settings-toggle" + icon="arrow-down-alt2" + label="Link settings" + onClick={[Function]} + /> + </div> + <div + className="editor-url-popover__row block-editor-url-popover__row editor-url-popover__settings block-editor-url-popover__settings" + > + <div> + Settings + </div> + </div> + </div> +</Popover> +`; + +exports[`URLPopover matches the snapshot when there are no settings 1`] = ` +<Popover + className="editor-url-popover block-editor-url-popover" + focusOnMount="firstElement" + position="bottom center" +> + <div + className="block-editor-url-popover__input-container" + > + <div + className="editor-url-popover__row block-editor-url-popover__row" + > + <div> + Editor + </div> + </div> + </div> +</Popover> +`; diff --git a/gb-src/packages/block-editor/src/components/url-popover/test/index.js b/gb-src/packages/block-editor/src/components/url-popover/test/index.js new file mode 100644 index 0000000000000..e541343151034 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/url-popover/test/index.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import URLPopover from '../'; + +describe( 'URLPopover', () => { + it( 'matches the snapshot in its default state', () => { + const wrapper = shallow( + <URLPopover + renderSettings={ () => ( + <div>Settings</div> + ) } + > + <div>Editor</div> + </URLPopover> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when the settings are toggled open', () => { + const wrapper = shallow( + <URLPopover + renderSettings={ () => ( + <div>Settings</div> + ) } + > + <div>Editor</div> + </URLPopover> + ); + + const toggleButton = wrapper.find( '.block-editor-url-popover__settings-toggle' ); + expect( toggleButton ).toHaveLength( 1 ); + toggleButton.simulate( 'click' ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when there are no settings', () => { + const wrapper = shallow( + <URLPopover> + <div>Editor</div> + </URLPopover> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/warning/index.js b/gb-src/packages/block-editor/src/components/warning/index.js new file mode 100644 index 0000000000000..38e7dc18a6450 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/index.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Children } from '@wordpress/element'; +import { Dropdown, IconButton, MenuGroup, MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +function Warning( { className, actions, children, secondaryActions } ) { + return ( + <div className={ classnames( className, 'editor-warning block-editor-warning' ) }> + <div className="editor-warning__contents block-editor-warning__contents"> + <p className="editor-warning__message block-editor-warning__message">{ children }</p> + + { Children.count( actions ) > 0 && ( + <div className="editor-warning__actions block-editor-warning__actions"> + { Children.map( actions, ( action, i ) => ( + <span key={ i } className="editor-warning__action block-editor-warning__action"> + { action } + </span> + ) ) } + </div> + ) } + </div> + + { secondaryActions && ( + <Dropdown + className="editor-warning__secondary block-editor-warning__secondary" + position="bottom left" + renderToggle={ ( { isOpen, onToggle } ) => ( + <IconButton + icon="ellipsis" + label={ __( 'More options' ) } + onClick={ onToggle } + aria-expanded={ isOpen } + /> + ) } + renderContent={ () => ( + <MenuGroup> + { secondaryActions.map( ( item, pos ) => + <MenuItem onClick={ item.onClick } key={ pos }> + { item.title } + </MenuItem> + ) } + </MenuGroup> + ) } + /> + ) } + </div> + ); +} + +export default Warning; diff --git a/gb-src/packages/block-editor/src/components/warning/index.native.js b/gb-src/packages/block-editor/src/components/warning/index.native.js new file mode 100644 index 0000000000000..7d0cced3408cc --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/index.native.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { View, Text } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Icon } from '@wordpress/components'; +import { normalizeIconObject } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +function Warning( { title, message, icon, iconClass, ...viewProps } ) { + icon = icon && normalizeIconObject( icon ); + return ( + <View + style={ styles.container } + { ...viewProps } + > + { icon && ( + <View style={ styles.icon }> + <Icon + className={ iconClass || 'warning-icon' } + icon={ icon && icon.src ? icon.src : icon } + /> + </View> + ) } + { title && ( + <Text style={ styles.title }>{ title }</Text> + ) } + { message && ( + <Text style={ styles.message }>{ message }</Text> + ) } + </View> + ); +} + +export default Warning; diff --git a/gb-src/packages/block-editor/src/components/warning/style.native.scss b/gb-src/packages/block-editor/src/components/warning/style.native.scss new file mode 100644 index 0000000000000..a96045ada1fd5 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/style.native.scss @@ -0,0 +1,31 @@ +/** @format */ + +.container { + background-color: $gray-lighten-30; + padding-top: 24; + padding-bottom: 24; + padding-left: 8; + padding-right: 8; + align-items: center; + justify-content: center; +} + +.icon { + width: 24px; + height: 24px; + margin-bottom: 4; + fill: $gray-dark; +} + +.title { + text-align: center; + margin-bottom: 6; + font-size: 14; + color: $gray-dark; +} + +.message { + text-align: center; + color: $gray-text-min; + font-size: 12; +} diff --git a/gb-src/packages/block-editor/src/components/warning/style.scss b/gb-src/packages/block-editor/src/components/warning/style.scss new file mode 100644 index 0000000000000..3f6c78825c68b --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/style.scss @@ -0,0 +1,80 @@ +.block-editor-warning { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: nowrap; + background-color: $white; + border: $border-width solid $light-gray-500; + text-align: left; + padding: 10px $block-padding $block-padding; + + // Avoid conflict with the multi-selection highlight color. + .has-warning.is-multi-selected & { + background-color: transparent; + } + + .is-selected & { + // Use opacity to work in various editor styles. + border-color: $dark-opacity-light-800; + + @include break-small { + border-left-color: transparent; + } + + .is-dark-theme & { + border-color: $light-opacity-light-800; + } + } + + .block-editor-warning__message { + line-height: $default-line-height; + font-family: $default-font; + font-size: $default-font-size; + margin: 1em 0; + + } + // Required extra-specifity to override paragraph block styles. + p.block-editor-warning__message.block-editor-warning__message { + min-height: auto; + } + + .block-editor-warning__contents { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + align-items: baseline; + width: 100%; + } + + .block-editor-warning__actions { + display: flex; + } + + .block-editor-warning__action { + margin: 0 6px 0 0; + } +} + +.block-editor-warning__secondary { + margin: 5px 0 0 -4px; + height: $block-controls-height; + + // the padding and margin of the more menu is intentionally non-standard + .components-icon-button { + width: auto; + padding: 8px 2px; + } + + @include break-small() { + margin-left: 4px; + + .components-icon-button { + padding: 8px 4px; + } + } + + .components-button svg { + transform: rotate(90deg); + } +} diff --git a/gb-src/packages/block-editor/src/components/warning/test/__snapshots__/index.js.snap b/gb-src/packages/block-editor/src/components/warning/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..de9e8358fdb80 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/test/__snapshots__/index.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Warning should match snapshot 1`] = ` +<div + className="editor-warning block-editor-warning" +> + <div + className="editor-warning__contents block-editor-warning__contents" + > + <p + className="editor-warning__message block-editor-warning__message" + > + error + </p> + </div> +</div> +`; diff --git a/gb-src/packages/block-editor/src/components/warning/test/index.js b/gb-src/packages/block-editor/src/components/warning/test/index.js new file mode 100644 index 0000000000000..f1a10c4e6fbc3 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/warning/test/index.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Warning from '../index'; + +describe( 'Warning', () => { + it( 'should match snapshot', () => { + const wrapper = shallow( <Warning>error</Warning> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should have valid class', () => { + const wrapper = shallow( <Warning /> ); + + expect( wrapper.hasClass( 'block-editor-warning' ) ).toBe( true ); + expect( wrapper.find( '.block-editor-warning__actions' ) ).toHaveLength( 0 ); + expect( wrapper.find( '.block-editor-warning__hidden' ) ).toHaveLength( 0 ); + } ); + + it( 'should show child error message element', () => { + const wrapper = shallow( <Warning actions={ <button /> }>Message</Warning> ); + + const actions = wrapper.find( '.block-editor-warning__actions' ); + const action = actions.childAt( 0 ); + + expect( actions ).toHaveLength( 1 ); + expect( action.hasClass( 'block-editor-warning__action' ) ).toBe( true ); + expect( action.childAt( 0 ).type() ).toBe( 'button' ); + } ); + + it( 'should show hidden actions', () => { + const wrapper = shallow( <Warning secondaryActions={ [ { title: 'test', onClick: null } ] }>Message</Warning> ); + + const actions = wrapper.find( '.block-editor-warning__secondary' ); + + expect( actions ).toHaveLength( 1 ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/components/writing-flow/index.js b/gb-src/packages/block-editor/src/components/writing-flow/index.js new file mode 100644 index 0000000000000..37e45dd2680d0 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/writing-flow/index.js @@ -0,0 +1,456 @@ +/** + * External dependencies + */ +import { overEvery, find, findLast, reverse, first, last } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { + computeCaretRect, + focus, + isHorizontalEdge, + isTextField, + isVerticalEdge, + placeCaretAtHorizontalEdge, + placeCaretAtVerticalEdge, + isEntirelySelected, +} from '@wordpress/dom'; +import { UP, DOWN, LEFT, RIGHT, TAB, isKeyboardEvent } from '@wordpress/keycodes'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { + isBlockFocusStop, + isInSameBlock, + hasInnerBlocksContext, +} from '../../utils/dom'; + +/** + * Browser constants + */ + +const { getSelection, getComputedStyle } = window; + +/** + * Given an element, returns true if the element is a tabbable text field, or + * false otherwise. + * + * @param {Element} element Element to test. + * + * @return {boolean} Whether element is a tabbable text field. + */ +const isTabbableTextField = overEvery( [ + isTextField, + focus.tabbable.isTabbableIndex, +] ); + +/** + * Returns true if the element should consider edge navigation upon a keyboard + * event of the given directional key code, or false otherwise. + * + * @param {Element} element HTML element to test. + * @param {number} keyCode KeyboardEvent keyCode to test. + * @param {boolean} hasModifier Whether a modifier is pressed. + * + * @return {boolean} Whether element should consider edge navigation. + */ +export function isNavigationCandidate( element, keyCode, hasModifier ) { + const isVertical = ( keyCode === UP || keyCode === DOWN ); + + // Currently, all elements support unmodified vertical navigation. + if ( isVertical && ! hasModifier ) { + return true; + } + + // Native inputs should not navigate horizontally. + const { tagName } = element; + return tagName !== 'INPUT' && tagName !== 'TEXTAREA'; +} + +class WritingFlow extends Component { + constructor() { + super( ...arguments ); + + this.onKeyDown = this.onKeyDown.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + this.onMouseDown = this.onMouseDown.bind( this ); + this.focusLastTextField = this.focusLastTextField.bind( this ); + + /** + * Here a rectangle is stored while moving the caret vertically so + * vertical position of the start position can be restored. + * This is to recreate browser behaviour across blocks. + * + * @type {?DOMRect} + */ + this.verticalRect = null; + + /** + * Reference of the writing flow appender element. + * The reference is used to focus the first tabbable element after the block list + * once we hit `tab` on the last block in navigation mode. + */ + this.appender = createRef(); + } + + bindContainer( ref ) { + this.container = ref; + } + + onMouseDown() { + this.verticalRect = null; + this.disableNavigationMode(); + } + + disableNavigationMode() { + if ( this.props.isNavigationMode ) { + this.props.disableNavigationMode(); + } + } + + /** + * Returns the optimal tab target from the given focused element in the + * desired direction. A preference is made toward text fields, falling back + * to the block focus stop if no other candidates exist for the block. + * + * @param {Element} target Currently focused text field. + * @param {boolean} isReverse True if considering as the first field. + * + * @return {?Element} Optimal tab target, if one exists. + */ + getClosestTabbable( target, isReverse ) { + // Since the current focus target is not guaranteed to be a text field, + // find all focusables. Tabbability is considered later. + let focusableNodes = focus.focusable.find( this.container ); + + if ( isReverse ) { + focusableNodes = reverse( focusableNodes ); + } + + // Consider as candidates those focusables after the current target. + // It's assumed this can only be reached if the target is focusable + // (on its keydown event), so no need to verify it exists in the set. + focusableNodes = focusableNodes.slice( focusableNodes.indexOf( target ) + 1 ); + + function isTabCandidate( node, i, array ) { + // Not a candidate if the node is not tabbable. + if ( ! focus.tabbable.isTabbableIndex( node ) ) { + return false; + } + + // Prefer text fields... + if ( isTextField( node ) ) { + return true; + } + + // ...but settle for block focus stop. + if ( ! isBlockFocusStop( node ) ) { + return false; + } + + // If element contains inner blocks, stop immediately at its focus + // wrapper. + if ( hasInnerBlocksContext( node ) ) { + return true; + } + + // If navigating out of a block (in reverse), don't consider its + // block focus stop. + if ( node.contains( target ) ) { + return false; + } + + // In case of block focus stop, check to see if there's a better + // text field candidate within. + for ( let offset = 1, nextNode; ( nextNode = array[ i + offset ] ); offset++ ) { + // Abort if no longer testing descendents of focus stop. + if ( ! node.contains( nextNode ) ) { + break; + } + + // Apply same tests by recursion. This is important to consider + // nestable blocks where we don't want to settle for the inner + // block focus stop. + if ( isTabCandidate( nextNode, i + offset, array ) ) { + return false; + } + } + + return true; + } + + return find( focusableNodes, isTabCandidate ); + } + + expandSelection( isReverse ) { + const { + selectedBlockClientId, + selectionStartClientId, + selectionBeforeEndClientId, + selectionAfterEndClientId, + } = this.props; + + const nextSelectionEndClientId = isReverse ? + selectionBeforeEndClientId : + selectionAfterEndClientId; + + if ( nextSelectionEndClientId ) { + this.props.onMultiSelect( + selectionStartClientId || selectedBlockClientId, + nextSelectionEndClientId + ); + } + } + + moveSelection( isReverse ) { + const { selectedFirstClientId, selectedLastClientId } = this.props; + + const focusedBlockClientId = isReverse ? selectedFirstClientId : selectedLastClientId; + + if ( focusedBlockClientId ) { + this.props.onSelectBlock( focusedBlockClientId ); + } + } + + /** + * Returns true if the given target field is the last in its block which + * can be considered for tab transition. For example, in a block with two + * text fields, this would return true when reversing from the first of the + * two fields, but false when reversing from the second. + * + * @param {Element} target Currently focused text field. + * @param {boolean} isReverse True if considering as the first field. + * + * @return {boolean} Whether field is at edge for tab transition. + */ + isTabbableEdge( target, isReverse ) { + const closestTabbable = this.getClosestTabbable( target, isReverse ); + return ! closestTabbable || ! isInSameBlock( target, closestTabbable ); + } + + onKeyDown( event ) { + const { + hasMultiSelection, + onMultiSelect, + blocks, + selectedBlockClientId, + selectionBeforeEndClientId, + selectionAfterEndClientId, + isNavigationMode, + } = this.props; + + const { keyCode, target } = event; + const isUp = keyCode === UP; + const isDown = keyCode === DOWN; + const isLeft = keyCode === LEFT; + const isRight = keyCode === RIGHT; + const isTab = keyCode === TAB; + const isReverse = isUp || isLeft; + const isHorizontal = isLeft || isRight; + const isVertical = isUp || isDown; + const isNav = isHorizontal || isVertical; + const isShift = event.shiftKey; + const hasModifier = isShift || event.ctrlKey || event.altKey || event.metaKey; + const isNavEdge = isVertical ? isVerticalEdge : isHorizontalEdge; + + // In navigation mode, tab and arrows navigate from block to block. + if ( isNavigationMode ) { + const navigateUp = ( isTab && isShift ) || isUp; + const navigateDown = ( isTab && ! isShift ) || isDown; + const focusedBlockUid = navigateUp ? selectionBeforeEndClientId : selectionAfterEndClientId; + + if ( + ( navigateDown || navigateUp ) && + focusedBlockUid + ) { + event.preventDefault(); + this.props.onSelectBlock( focusedBlockUid ); + } + + // Special case when reaching the end of the blocks (navigate to the next tabbable outside of the writing flow) + if ( navigateDown && selectedBlockClientId && ! selectionAfterEndClientId && [ UP, DOWN ].indexOf( keyCode ) === -1 ) { + this.props.clearSelectedBlock(); + this.appender.current.focus(); + } + return; + } + + // When presing any key other than up or down, the initial vertical + // position must ALWAYS be reset. The vertical position is saved so it + // can be restored as well as possible on sebsequent vertical arrow key + // presses. It may not always be possible to restore the exact same + // position (such as at an empty line), so it wouldn't be good to + // compute the position right before any vertical arrow key press. + if ( ! isVertical ) { + this.verticalRect = null; + } else if ( ! this.verticalRect ) { + this.verticalRect = computeCaretRect(); + } + + // This logic inside this condition needs to be checked before + // the check for event.nativeEvent.defaultPrevented. + // The logic handles meta+a keypress and this event is default prevented + // by RichText. + if ( ! isNav ) { + // Set immediately before the meta+a combination can be pressed. + if ( isKeyboardEvent.primary( event ) ) { + this.isEntirelySelected = isEntirelySelected( target ); + } + + if ( isKeyboardEvent.primary( event, 'a' ) ) { + // When the target is contentEditable, selection will already + // have been set by the browser earlier in this call stack. We + // need check the previous result, otherwise all blocks will be + // selected right away. + if ( target.isContentEditable ? this.isEntirelySelected : isEntirelySelected( target ) ) { + onMultiSelect( first( blocks ), last( blocks ) ); + event.preventDefault(); + } + + // After pressing primary + A we can assume isEntirelySelected is true. + // Calling right away isEntirelySelected after primary + A may still return false on some browsers. + this.isEntirelySelected = true; + } + + return; + } + + // Abort if navigation has already been handled (e.g. RichText inline + // boundaries). + if ( event.nativeEvent.defaultPrevented ) { + return; + } + + // Abort if our current target is not a candidate for navigation (e.g. + // preserve native input behaviors). + if ( ! isNavigationCandidate( target, keyCode, hasModifier ) ) { + return; + } + + // In the case of RTL scripts, right means previous and left means next, + // which is the exact reverse of LTR. + const { direction } = getComputedStyle( target ); + const isReverseDir = direction === 'rtl' ? ( ! isReverse ) : isReverse; + + if ( isShift ) { + if ( + ( + // Ensure that there is a target block. + ( isReverse && selectionBeforeEndClientId ) || + ( ! isReverse && selectionAfterEndClientId ) + ) && ( + hasMultiSelection || ( + this.isTabbableEdge( target, isReverse ) && + isNavEdge( target, isReverse ) + ) + ) + ) { + // Shift key is down, and there is multi selection or we're at + // the end of the current block. + this.expandSelection( isReverse ); + event.preventDefault(); + } + } else if ( hasMultiSelection ) { + // Moving from block multi-selection to single block selection + this.moveSelection( isReverse ); + event.preventDefault(); + } else if ( isVertical && isVerticalEdge( target, isReverse ) ) { + const closestTabbable = this.getClosestTabbable( target, isReverse ); + + if ( closestTabbable ) { + placeCaretAtVerticalEdge( closestTabbable, isReverse, this.verticalRect ); + event.preventDefault(); + } + } else if ( isHorizontal && getSelection().isCollapsed && isHorizontalEdge( target, isReverseDir ) ) { + const closestTabbable = this.getClosestTabbable( target, isReverseDir ); + placeCaretAtHorizontalEdge( closestTabbable, isReverseDir ); + event.preventDefault(); + } + } + + /** + * Sets focus to the end of the last tabbable text field, if one exists. + */ + focusLastTextField() { + this.disableNavigationMode(); + const focusableNodes = focus.focusable.find( this.container ); + const target = findLast( focusableNodes, isTabbableTextField ); + if ( target ) { + placeCaretAtHorizontalEdge( target, true ); + } + } + + render() { + const { children } = this.props; + + // Disable reason: Wrapper itself is non-interactive, but must capture + // bubbling events from children to determine focus transition intents. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div className="editor-writing-flow block-editor-writing-flow"> + <div + ref={ this.bindContainer } + onKeyDown={ this.onKeyDown } + onMouseDown={ this.onMouseDown } + > + { children } + </div> + <div + ref={ this.appender } + aria-hidden + tabIndex={ -1 } + onClick={ this.focusLastTextField } + className="editor-writing-flow__click-redirect block-editor-writing-flow__click-redirect" + /> + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + getSelectedBlockClientId, + getMultiSelectedBlocksStartClientId, + getMultiSelectedBlocksEndClientId, + getPreviousBlockClientId, + getNextBlockClientId, + getFirstMultiSelectedBlockClientId, + getLastMultiSelectedBlockClientId, + hasMultiSelection, + getBlockOrder, + isNavigationMode, + } = select( 'core/block-editor' ); + + const selectedBlockClientId = getSelectedBlockClientId(); + const selectionStartClientId = getMultiSelectedBlocksStartClientId(); + const selectionEndClientId = getMultiSelectedBlocksEndClientId(); + + return { + selectedBlockClientId, + selectionStartClientId, + selectionBeforeEndClientId: getPreviousBlockClientId( selectionEndClientId || selectedBlockClientId ), + selectionAfterEndClientId: getNextBlockClientId( selectionEndClientId || selectedBlockClientId ), + selectedFirstClientId: getFirstMultiSelectedBlockClientId(), + selectedLastClientId: getLastMultiSelectedBlockClientId(), + hasMultiSelection: hasMultiSelection(), + blocks: getBlockOrder(), + isNavigationMode: isNavigationMode(), + }; + } ), + withDispatch( ( dispatch ) => { + const { multiSelect, selectBlock, setNavigationMode, clearSelectedBlock } = dispatch( 'core/block-editor' ); + return { + onMultiSelect: multiSelect, + onSelectBlock: selectBlock, + disableNavigationMode: () => setNavigationMode( false ), + clearSelectedBlock, + }; + } ), +] )( WritingFlow ); diff --git a/gb-src/packages/block-editor/src/components/writing-flow/style.scss b/gb-src/packages/block-editor/src/components/writing-flow/style.scss new file mode 100644 index 0000000000000..422b378f2e8e2 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/writing-flow/style.scss @@ -0,0 +1,8 @@ +.block-editor-writing-flow { + display: flex; + flex-direction: column; +} + +.block-editor-writing-flow__click-redirect { + cursor: text; +} diff --git a/gb-src/packages/block-editor/src/components/writing-flow/test/index.js b/gb-src/packages/block-editor/src/components/writing-flow/test/index.js new file mode 100644 index 0000000000000..88ff30f107a72 --- /dev/null +++ b/gb-src/packages/block-editor/src/components/writing-flow/test/index.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { isNavigationCandidate } from '../'; + +describe( 'isNavigationCandidate', () => { + let elements; + beforeAll( () => { + elements = {}; + elements.input = document.createElement( 'input' ); + elements.contentEditable = document.createElement( 'p' ); + elements.contentEditable.contentEditable = true; + } ); + + it( 'should return true if vertically navigating input without modifier', () => { + [ UP, DOWN ].forEach( ( keyCode ) => { + const result = isNavigationCandidate( elements.input, keyCode, false ); + + expect( result ).toBe( true ); + } ); + } ); + + it( 'should return false if vertically navigating input with modifier', () => { + [ UP, DOWN ].forEach( ( keyCode ) => { + const result = isNavigationCandidate( elements.input, keyCode, true ); + + expect( result ).toBe( false ); + } ); + } ); + + it( 'should return false if horizontally navigating input', () => { + [ LEFT, RIGHT ].forEach( ( keyCode ) => { + const result = isNavigationCandidate( elements.input, keyCode, false ); + + expect( result ).toBe( false ); + } ); + } ); + + it( 'should return true if horizontally navigating non-input', () => { + [ LEFT, RIGHT ].forEach( ( keyCode ) => { + const result = isNavigationCandidate( elements.contentEditable, keyCode, false ); + + expect( result ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/hooks/align.js b/gb-src/packages/block-editor/src/hooks/align.js new file mode 100644 index 0000000000000..635db6b65f68e --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/align.js @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { assign, get, has, includes, without } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose, createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; +import { getBlockSupport, getBlockType, hasBlockSupport } from '@wordpress/blocks'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { BlockControls, BlockAlignmentToolbar } from '../components'; + +/** + * An array which includes all possible valid alignments, + * used to validate if an alignment is valid or not. + * + * @constant + * @type {string[]} + */ +const ALL_ALIGNMENTS = [ 'left', 'center', 'right', 'wide', 'full' ]; + +/** + * An array which includes all wide alignments. + * In order for this alignments to be valid they need to be supported by the block, + * and by the theme. + * + * @constant + * @type {string[]} + */ +const WIDE_ALIGNMENTS = [ 'wide', 'full' ]; + +/** + * Returns the valid alignments. + * Takes into consideration the aligns supported by a block, if the block supports wide controls or not and if theme supports wide controls or not. + * Exported just for testing purposes, not exported outside the module. + * + * @param {?boolean|string[]} blockAlign Aligns supported by the block. + * @param {?boolean} hasWideBlockSupport True if block supports wide alignments. And False otherwise. + * @param {?boolean} hasWideEnabled True if theme supports wide alignments. And False otherwise. + * + * @return {string[]} Valid alignments. + */ +export function getValidAlignments( blockAlign, hasWideBlockSupport = true, hasWideEnabled = true ) { + let validAlignments; + if ( Array.isArray( blockAlign ) ) { + validAlignments = blockAlign; + } else if ( blockAlign === true ) { + // `true` includes all alignments... + validAlignments = ALL_ALIGNMENTS; + } else { + validAlignments = []; + } + + if ( + ! hasWideEnabled || + ( blockAlign === true && ! hasWideBlockSupport ) + ) { + return without( validAlignments, ...WIDE_ALIGNMENTS ); + } + + return validAlignments; +} + +/** + * Filters registered block settings, extending attributes to include `align`. + * + * @param {Object} settings Original block settings + * @return {Object} Filtered block settings + */ +export function addAttribute( settings ) { + // allow blocks to specify their own attribute definition with default values if needed. + if ( has( settings.attributes, [ 'align', 'type' ] ) ) { + return settings; + } + if ( hasBlockSupport( settings, 'align' ) ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + align: { + type: 'string', + }, + } ); + } + + return settings; +} + +/** + * Override the default edit UI to include new toolbar controls for block + * alignment, if block defines support. + * + * @param {Function} BlockEdit Original component + * @return {Function} Wrapped component + */ +export const withToolbarControls = createHigherOrderComponent( + ( BlockEdit ) => ( + ( props ) => { + const { name: blockName } = props; + // Compute valid alignments without taking into account, + // if the theme supports wide alignments or not. + // BlockAlignmentToolbar takes into account the theme support. + const validAlignments = getValidAlignments( + getBlockSupport( blockName, 'align' ), + hasBlockSupport( blockName, 'alignWide', true ), + ); + + const updateAlignment = ( nextAlign ) => { + if ( ! nextAlign ) { + const blockType = getBlockType( props.name ); + const blockDefaultAlign = get( blockType, [ 'attributes', 'align', 'default' ] ); + if ( blockDefaultAlign ) { + nextAlign = ''; + } + } + props.setAttributes( { align: nextAlign } ); + }; + + return [ + validAlignments.length > 0 && props.isSelected && ( + <BlockControls key="align-controls"> + <BlockAlignmentToolbar + value={ props.attributes.align } + onChange={ updateAlignment } + controls={ validAlignments } + /> + </BlockControls> + ), + <BlockEdit key="edit" { ...props } />, + ]; + } + ), + 'withToolbarControls' +); + +// Exported just for testing purposes, not exported outside the module. +export const insideSelectWithDataAlign = ( BlockListBlock ) => ( + ( props ) => { + const { name, attributes, hasWideEnabled } = props; + const { align } = attributes; + const validAlignments = getValidAlignments( + getBlockSupport( name, 'align' ), + hasBlockSupport( name, 'alignWide', true ), + hasWideEnabled + ); + + let wrapperProps = props.wrapperProps; + if ( includes( validAlignments, align ) ) { + wrapperProps = { ...wrapperProps, 'data-align': align }; + } + + return <BlockListBlock { ...props } wrapperProps={ wrapperProps } />; + } +); + +/** + * Override the default block element to add alignment wrapper props. + * + * @param {Function} BlockListBlock Original component + * @return {Function} Wrapped component + */ +export const withDataAlign = createHigherOrderComponent( + compose( [ + withSelect( + ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + return { + hasWideEnabled: !! getSettings().alignWide, + }; + } + ), + insideSelectWithDataAlign, + ] ) +); + +/** + * Override props assigned to save component to inject alignment class name if + * block supports it. + * + * @param {Object} props Additional props applied to save element + * @param {Object} blockType Block type + * @param {Object} attributes Block attributes + * @return {Object} Filtered props applied to save element + */ +export function addAssignedAlign( props, blockType, attributes ) { + const { align } = attributes; + const blockAlign = getBlockSupport( blockType, 'align' ); + const hasWideBlockSupport = hasBlockSupport( blockType, 'alignWide', true ); + const isAlignValid = includes( + // Compute valid alignments without taking into account, + // if the theme supports wide alignments or not. + // This way changing themes does not impacts the block save. + getValidAlignments( blockAlign, hasWideBlockSupport ), + align + ); + if ( isAlignValid ) { + props.className = classnames( `align${ align }`, props.className ); + } + + return props; +} + +addFilter( 'blocks.registerBlockType', 'core/align/addAttribute', addAttribute ); +addFilter( 'editor.BlockListBlock', 'core/editor/align/with-data-align', withDataAlign ); +addFilter( 'editor.BlockEdit', 'core/editor/align/with-toolbar-controls', withToolbarControls ); +addFilter( 'blocks.getSaveContent.extraProps', 'core/align/addAssignedAlign', addAssignedAlign ); + diff --git a/gb-src/packages/block-editor/src/hooks/anchor.js b/gb-src/packages/block-editor/src/hooks/anchor.js new file mode 100644 index 0000000000000..9b78c39bbfeb0 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/anchor.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { assign, has } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { TextControl, ExternalLink } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { InspectorAdvancedControls } from '../components'; + +/** + * Regular expression matching invalid anchor characters for replacement. + * + * @type {RegExp} + */ +const ANCHOR_REGEX = /[\s#]/g; + +/** + * Filters registered block settings, extending attributes with anchor using ID + * of the first node. + * + * @param {Object} settings Original block settings. + * + * @return {Object} Filtered block settings. + */ +export function addAttribute( settings ) { + // allow blocks to specify their own attribute definition with default values if needed. + if ( has( settings.attributes, [ 'anchor', 'type' ] ) ) { + return settings; + } + if ( hasBlockSupport( settings, 'anchor' ) ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + anchor: { + type: 'string', + source: 'attribute', + attribute: 'id', + selector: '*', + }, + } ); + } + + return settings; +} + +/** + * Override the default edit UI to include a new block inspector control for + * assigning the anchor ID, if block supports anchor. + * + * @param {Function|Component} BlockEdit Original component. + * + * @return {string} Wrapped component. + */ +export const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const hasAnchor = hasBlockSupport( props.name, 'anchor' ); + + if ( hasAnchor && props.isSelected ) { + return ( + <> + <BlockEdit { ...props } /> + <InspectorAdvancedControls> + <TextControl + className="html-anchor-control" + label={ __( 'HTML Anchor' ) } + help={ ( + <> + { __( 'Enter a word or two — without spaces — to make a unique web address just for this heading, called an “anchor.” Then, you’ll be able to link directly to this section of your page.' ) } + + <ExternalLink href={ 'https://wordpress.org/support/article/page-jumps/' }> + { __( 'Learn more about anchors' ) } + </ExternalLink> + </> + ) } + value={ props.attributes.anchor || '' } + onChange={ ( nextValue ) => { + nextValue = nextValue.replace( ANCHOR_REGEX, '-' ); + props.setAttributes( { + anchor: nextValue, + } ); + } } /> + </InspectorAdvancedControls> + </> + ); + } + + return <BlockEdit { ...props } />; + }; +}, 'withInspectorControl' ); + +/** + * Override props assigned to save component to inject anchor ID, if block + * supports anchor. This is only applied if the block's save result is an + * element and not a markup string. + * + * @param {Object} extraProps Additional props applied to save element. + * @param {Object} blockType Block type. + * @param {Object} attributes Current block attributes. + * + * @return {Object} Filtered props applied to save element. + */ +export function addSaveProps( extraProps, blockType, attributes ) { + if ( hasBlockSupport( blockType, 'anchor' ) ) { + extraProps.id = attributes.anchor === '' ? null : attributes.anchor; + } + + return extraProps; +} + +addFilter( 'blocks.registerBlockType', 'core/anchor/attribute', addAttribute ); +addFilter( 'editor.BlockEdit', 'core/editor/anchor/with-inspector-control', withInspectorControl ); +addFilter( 'blocks.getSaveContent.extraProps', 'core/anchor/save-props', addSaveProps ); diff --git a/gb-src/packages/block-editor/src/hooks/anchor.scss b/gb-src/packages/block-editor/src/hooks/anchor.scss new file mode 100644 index 0000000000000..5987b154f41a4 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/anchor.scss @@ -0,0 +1,4 @@ +.html-anchor-control .components-external-link { + display: block; + margin-top: $grid-size; +} diff --git a/gb-src/packages/block-editor/src/hooks/custom-class-name.js b/gb-src/packages/block-editor/src/hooks/custom-class-name.js new file mode 100644 index 0000000000000..bac77a37ea42a --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/custom-class-name.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { assign, difference, omit } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { TextControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + hasBlockSupport, + parseWithAttributeSchema, + getSaveContent, +} from '@wordpress/blocks'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { InspectorAdvancedControls } from '../components'; + +/** + * Filters registered block settings, extending attributes with anchor using ID + * of the first node. + * + * @param {Object} settings Original block settings. + * + * @return {Object} Filtered block settings. + */ +export function addAttribute( settings ) { + if ( hasBlockSupport( settings, 'customClassName', true ) ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + className: { + type: 'string', + }, + } ); + } + + return settings; +} + +/** + * Override the default edit UI to include a new block inspector control for + * assigning the custom class name, if block supports custom class name. + * + * @param {Function|Component} BlockEdit Original component. + * + * @return {string} Wrapped component. + */ +export const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const hasCustomClassName = hasBlockSupport( props.name, 'customClassName', true ); + if ( hasCustomClassName && props.isSelected ) { + return ( + <> + <BlockEdit { ...props } /> + <InspectorAdvancedControls> + <TextControl + label={ __( 'Additional CSS Class(es)' ) } + value={ props.attributes.className || '' } + onChange={ ( nextValue ) => { + props.setAttributes( { + className: nextValue !== '' ? nextValue : undefined, + } ); + } } + help={ __( 'Separate multiple classes with spaces.' ) } + /> + </InspectorAdvancedControls> + </> + ); + } + + return <BlockEdit { ...props } />; + }; +}, 'withInspectorControl' ); + +/** + * Override props assigned to save component to inject anchor ID, if block + * supports anchor. This is only applied if the block's save result is an + * element and not a markup string. + * + * @param {Object} extraProps Additional props applied to save element. + * @param {Object} blockType Block type. + * @param {Object} attributes Current block attributes. + * + * @return {Object} Filtered props applied to save element. + */ +export function addSaveProps( extraProps, blockType, attributes ) { + if ( hasBlockSupport( blockType, 'customClassName', true ) && attributes.className ) { + extraProps.className = classnames( extraProps.className, attributes.className ); + } + + return extraProps; +} + +/** + * Given an HTML string, returns an array of class names assigned to the root + * element in the markup. + * + * @param {string} innerHTML Markup string from which to extract classes. + * + * @return {string[]} Array of class names assigned to the root element. + */ +export function getHTMLRootElementClasses( innerHTML ) { + innerHTML = `<div data-custom-class-name>${ innerHTML }</div>`; + + const parsed = parseWithAttributeSchema( innerHTML, { + type: 'string', + source: 'attribute', + selector: '[data-custom-class-name] > *', + attribute: 'class', + } ); + + return parsed ? parsed.trim().split( /\s+/ ) : []; +} + +/** + * Given a parsed set of block attributes, if the block supports custom class + * names and an unknown class (per the block's serialization behavior) is + * found, the unknown classes are treated as custom classes. This prevents the + * block from being considered as invalid. + * + * @param {Object} blockAttributes Original block attributes. + * @param {Object} blockType Block type settings. + * @param {string} innerHTML Original block markup. + * + * @return {Object} Filtered block attributes. + */ +export function addParsedDifference( blockAttributes, blockType, innerHTML ) { + if ( hasBlockSupport( blockType, 'customClassName', true ) ) { + // To determine difference, serialize block given the known set of + // attributes, with the exception of `className`. This will determine + // the default set of classes. From there, any difference in innerHTML + // can be considered as custom classes. + const attributesSansClassName = omit( blockAttributes, [ 'className' ] ); + const serialized = getSaveContent( blockType, attributesSansClassName ); + const defaultClasses = getHTMLRootElementClasses( serialized ); + const actualClasses = getHTMLRootElementClasses( innerHTML ); + const customClasses = difference( actualClasses, defaultClasses ); + + if ( customClasses.length ) { + blockAttributes.className = customClasses.join( ' ' ); + } else if ( serialized ) { + delete blockAttributes.className; + } + } + + return blockAttributes; +} + +addFilter( 'blocks.registerBlockType', 'core/custom-class-name/attribute', addAttribute ); +addFilter( 'editor.BlockEdit', 'core/editor/custom-class-name/with-inspector-control', withInspectorControl ); +addFilter( 'blocks.getSaveContent.extraProps', 'core/custom-class-name/save-props', addSaveProps ); +addFilter( 'blocks.getBlockAttributes', 'core/custom-class-name/addParsedDifference', addParsedDifference ); diff --git a/gb-src/packages/block-editor/src/hooks/custom-class-name.native.js b/gb-src/packages/block-editor/src/hooks/custom-class-name.native.js new file mode 100644 index 0000000000000..c59d604b90305 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/custom-class-name.native.js @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { assign, difference, compact } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { + hasBlockSupport, + getSaveContent, + parseWithAttributeSchema, +} from '@wordpress/blocks'; + +/** + * Filters registered block settings, extending attributes with anchor using ID + * of the first node. + * + * @param {Object} settings Original block settings. + * + * @return {Object} Filtered block settings. + */ +export function addAttribute( settings ) { + if ( hasBlockSupport( settings, 'customClassName', true ) ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + className: { + type: 'string', + }, + } ); + } + + return settings; +} + +/** + * Override props assigned to save component to inject anchor ID, if block + * supports anchor. This is only applied if the block's save result is an + * element and not a markup string. + * + * @param {Object} extraProps Additional props applied to save element. + * @param {Object} blockType Block type. + * @param {Object} attributes Current block attributes. + * + * @return {Object} Filtered props applied to save element. + */ +export function addSaveProps( extraProps, blockType, attributes ) { + if ( hasBlockSupport( blockType, 'customClassName', true ) && attributes.className ) { + extraProps.className = classnames( extraProps.className, attributes.className ); + } + + return extraProps; +} + +/** + * Given an HTML string, returns an array of class names assigned to the root + * element in the markup. + * + * @param {string} innerHTML Markup string from which to extract classes. + * + * @return {string[]} Array of class names assigned to the root element. + */ +export function getHTMLRootElementClasses( innerHTML ) { + innerHTML = `<div data-custom-class-name>${ innerHTML }</div>`; + + const parsed = parseWithAttributeSchema( innerHTML, { + type: 'string', + source: 'attribute', + selector: '[data-custom-class-name] > *', + attribute: 'class', + } ); + + return parsed ? parsed.trim().split( /\s+/ ) : []; +} + +/** + * Given a parsed set of block attributes, if the block supports custom class + * names and an unknown class (per the block's serialization behavior) is + * found, the unknown classes are treated as custom classes. This prevents the + * block from being considered as invalid. + * + * @param {Object} blockAttributes Original block attributes. + * @param {Object} blockType Block type settings. + * @param {string} innerHTML Original block markup. + * + * @return {Object} Filtered block attributes. + */ +export function addParsedDifference( blockAttributes, blockType, innerHTML ) { + if ( hasBlockSupport( blockType, 'customClassName', true ) ) { + // To determine difference, serialize block given the known set of + // attributes. If there are classes which are mismatched with the + // incoming HTML of the block, add to filtered result. + const serialized = getSaveContent( blockType, blockAttributes ); + const classes = getHTMLRootElementClasses( serialized ); + const parsedClasses = getHTMLRootElementClasses( innerHTML ); + const customClasses = difference( parsedClasses, classes ); + + const filteredClassName = compact( [ + blockAttributes.className, + ...customClasses, + ] ).join( ' ' ); + + if ( filteredClassName ) { + blockAttributes.className = filteredClassName; + } else { + delete blockAttributes.className; + } + } + + return blockAttributes; +} + +addFilter( 'blocks.registerBlockType', 'core/custom-class-name/attribute', addAttribute ); +addFilter( 'blocks.getSaveContent.extraProps', 'core/custom-class-name/save-props', addSaveProps ); +addFilter( 'blocks.getBlockAttributes', 'core/custom-class-name/addParsedDifference', addParsedDifference ); diff --git a/gb-src/packages/block-editor/src/hooks/generated-class-name.js b/gb-src/packages/block-editor/src/hooks/generated-class-name.js new file mode 100644 index 0000000000000..c5f7d1b49502c --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/generated-class-name.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { uniq } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { hasBlockSupport, getBlockDefaultClassName } from '@wordpress/blocks'; + +/** + * Override props assigned to save component to inject generated className if + * block supports it. This is only applied if the block's save result is an + * element and not a markup string. + * + * @param {Object} extraProps Additional props applied to save element. + * @param {Object} blockType Block type. + * + * @return {Object} Filtered props applied to save element. + */ +export function addGeneratedClassName( extraProps, blockType ) { + // Adding the generated className + if ( hasBlockSupport( blockType, 'className', true ) ) { + if ( typeof extraProps.className === 'string' ) { + // We have some extra classes and want to add the default classname + // We use uniq to prevent duplicate classnames + + extraProps.className = uniq( [ + getBlockDefaultClassName( blockType.name ), + ...extraProps.className.split( ' ' ), + ] ).join( ' ' ).trim(); + } else { + // There is no string in the className variable, + // so we just dump the default name in there + extraProps.className = getBlockDefaultClassName( blockType.name ); + } + } + return extraProps; +} + +addFilter( 'blocks.getSaveContent.extraProps', 'core/generated-class-name/save-props', addGeneratedClassName ); diff --git a/gb-src/packages/block-editor/src/hooks/index.js b/gb-src/packages/block-editor/src/hooks/index.js new file mode 100644 index 0000000000000..7bd9f390390c4 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/index.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import './align'; +import './anchor'; +import './custom-class-name'; +import './generated-class-name'; diff --git a/gb-src/packages/block-editor/src/hooks/index.native.js b/gb-src/packages/block-editor/src/hooks/index.native.js new file mode 100644 index 0000000000000..d85f61d596d93 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/index.native.js @@ -0,0 +1,5 @@ +/** + * Internal dependencies + */ +import './custom-class-name'; +import './generated-class-name'; diff --git a/gb-src/packages/block-editor/src/hooks/test/align.js b/gb-src/packages/block-editor/src/hooks/test/align.js new file mode 100644 index 0000000000000..995d3a9a59c4c --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/test/align.js @@ -0,0 +1,274 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import renderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; +import { + getBlockTypes, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + getValidAlignments, + withToolbarControls, + insideSelectWithDataAlign, + addAssignedAlign, +} from '../align'; + +describe( 'align', () => { + const blockSettings = { + save: noop, + category: 'common', + title: 'block title', + }; + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'addAttribute()', () => { + const filterRegisterBlockType = applyFilters.bind( null, 'blocks.registerBlockType' ); + + it( 'should do nothing if the block settings does not define align support', () => { + const settings = filterRegisterBlockType( blockSettings ); + + expect( settings.attributes ).toBeUndefined(); + } ); + + it( 'should assign a new align attribute', () => { + const settings = filterRegisterBlockType( { + ...blockSettings, + supports: { + align: true, + }, + } ); + + expect( settings.attributes ).toHaveProperty( 'align' ); + } ); + } ); + + describe( 'getValidAlignments()', () => { + it( 'should return an empty array if block does not define align support', () => { + expect( getValidAlignments() ).toEqual( [] ); + } ); + + it( 'should return all custom aligns set', () => { + expect( + getValidAlignments( [ 'left', 'right' ] ) + ).toEqual( + [ 'left', 'right' ] + ); + } ); + + it( 'should return all aligns if block defines align support as true', () => { + expect( + getValidAlignments( true ) + ).toEqual( + [ 'left', 'center', 'right', 'wide', 'full' ] + ); + } ); + + it( 'should return all aligns except wide if wide align explicitly false on the block', () => { + expect( + getValidAlignments( true, false, true ) + ).toEqual( [ 'left', 'center', 'right' ] ); + + expect( + getValidAlignments( true, false, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); + } ); + + it( 'should return all aligns except wide if wide align is not supported by the theme', () => { + expect( + getValidAlignments( true, true, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); + + expect( + getValidAlignments( true, false, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); + } ); + + it( 'should not remove wide aligns if they are not supported by the block and were set using an array in supports align', () => { + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], false, true ) + ).toEqual( [ 'left', 'right', 'wide', 'full' ] ); + } ); + + it( 'should remove wide aligns if they are not supported by the theme and were set using an array in supports align', () => { + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], true, false ) + ).toEqual( [ 'left', 'right' ] ); + + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], false, false ) + ).toEqual( [ 'left', 'right' ] ); + } ); + } ); + + describe( 'withToolbarControls', () => { + it( 'should do nothing if no valid alignments', () => { + registerBlockType( 'core/foo', blockSettings ); + + const EnhancedComponent = withToolbarControls( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + name="core/foo" + attributes={ {} } + isSelected + /> + ); + // when there's only one child, `rendered` in the tree is an object not an array. + expect( wrapper.toTree().rendered ).toBeInstanceOf( Object ); + } ); + + it( 'should render toolbar controls if valid alignments', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + alignWide: false, + }, + } ); + + const EnhancedComponent = withToolbarControls( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + name="core/foo" + attributes={ {} } + isSelected + /> + ); + expect( wrapper.toTree().rendered ).toHaveLength( 2 ); + } ); + } ); + + describe( 'withDataAlign', () => { + it( 'should render with wrapper props', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + alignWide: true, + }, + } ); + + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + attributes={ { + align: 'wide', + } } + name="core/foo" + /> + ); + expect( wrapper.toTree().rendered.props.wrapperProps ).toEqual( { + 'data-align': 'wide', + } ); + } ); + + it( 'should not render wide/full wrapper props if wide controls are not enabled', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + alignWide: true, + }, + } ); + + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + name="core/foo" + attributes={ { + align: 'wide', + } } + hasWideEnabled={ false } + /> + ); + expect( wrapper.toTree().rendered.props.wrapperProps ).toEqual( undefined ); + } ); + + it( 'should not render invalid align', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + alignWide: false, + }, + } ); + + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + name="core/foo" + attributes={ { + align: 'wide', + } } + /> + ); + + expect( wrapper.toTree().props.wrapperProps ).toBeUndefined(); + } ); + } ); + + describe( 'addAssignedAlign', () => { + it( 'should do nothing if block does not support align', () => { + registerBlockType( 'core/foo', blockSettings ); + + const props = addAssignedAlign( { + className: 'foo', + }, 'core/foo', { + align: 'wide', + } ); + + expect( props ).toEqual( { + className: 'foo', + } ); + } ); + + it( 'should do add align classname if block supports align', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + }, + } ); + + const props = addAssignedAlign( { + className: 'foo', + }, 'core/foo', { + align: 'wide', + } ); + + expect( props ).toEqual( { + className: 'alignwide foo', + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/hooks/test/anchor.js b/gb-src/packages/block-editor/src/hooks/test/anchor.js new file mode 100644 index 0000000000000..d55a54fff332e --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/test/anchor.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import '../anchor'; + +describe( 'anchor', () => { + const blockSettings = { + save: noop, + category: 'common', + title: 'block title', + }; + + describe( 'addAttribute()', () => { + const registerBlockType = applyFilters.bind( null, 'blocks.registerBlockType' ); + + it( 'should do nothing if the block settings do not define anchor support', () => { + const settings = registerBlockType( blockSettings ); + + expect( settings.attributes ).toBe( undefined ); + } ); + + it( 'should assign a new anchor attribute', () => { + const settings = registerBlockType( { + ...blockSettings, + supports: { + anchor: true, + }, + } ); + + expect( settings.attributes ).toHaveProperty( 'anchor' ); + } ); + + it( 'should not override attributes defined in settings', () => { + const settings = registerBlockType( { + ...blockSettings, + supports: { + anchor: true, + }, + attributes: { + anchor: { + type: 'string', + default: 'testAnchor', + }, + }, + } ); + + expect( settings.attributes.anchor ).toEqual( { type: 'string', default: 'testAnchor' } ); + } ); + } ); + + describe( 'addSaveProps', () => { + const getSaveContentExtraProps = applyFilters.bind( null, 'blocks.getSaveContent.extraProps' ); + + it( 'should do nothing if the block settings do not define anchor support', () => { + const attributes = { anchor: 'foo' }; + const extraProps = getSaveContentExtraProps( {}, blockSettings, attributes ); + + expect( extraProps ).not.toHaveProperty( 'id' ); + } ); + + it( 'should inject anchor attribute ID', () => { + const attributes = { anchor: 'foo' }; + const extraProps = getSaveContentExtraProps( {}, { + ...blockSettings, + supports: { + anchor: true, + }, + }, attributes ); + + expect( extraProps.id ).toBe( 'foo' ); + } ); + + it( 'should remove an anchor attribute ID when feild is cleared', () => { + const attributes = { anchor: '' }; + const extraProps = getSaveContentExtraProps( {}, { + ...blockSettings, + supports: { + anchor: true, + }, + }, attributes ); + + expect( extraProps.id ).toBe( null ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/hooks/test/custom-class-name.js b/gb-src/packages/block-editor/src/hooks/test/custom-class-name.js new file mode 100644 index 0000000000000..6b2c84b886139 --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/test/custom-class-name.js @@ -0,0 +1,170 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { getHTMLRootElementClasses } from '../custom-class-name'; + +describe( 'custom className', () => { + const blockSettings = { + save: () => <div className="default" />, + category: 'common', + title: 'block title', + }; + + const dynamicBlockSettings = { + save: () => null, + category: 'common', + title: 'block title', + }; + + describe( 'addAttribute()', () => { + const addAttribute = applyFilters.bind( null, 'blocks.registerBlockType' ); + + it( 'should do nothing if the block settings disable custom className support', () => { + const settings = addAttribute( { + ...blockSettings, + supports: { + customClassName: false, + }, + } ); + + expect( settings.attributes ).toBe( undefined ); + } ); + + it( 'should assign a new custom className attribute', () => { + const settings = addAttribute( blockSettings ); + + expect( settings.attributes ).toHaveProperty( 'className' ); + } ); + } ); + + describe( 'addSaveProps', () => { + const addSaveProps = applyFilters.bind( null, 'blocks.getSaveContent.extraProps' ); + + it( 'should do nothing if the block settings do not define custom className support', () => { + const attributes = { className: 'foo' }; + const extraProps = addSaveProps( {}, { + ...blockSettings, + supports: { + customClassName: false, + }, + }, attributes ); + + expect( extraProps ).not.toHaveProperty( 'className' ); + } ); + + it( 'should inject the custom className', () => { + const attributes = { className: 'bar' }; + const extraProps = addSaveProps( { className: 'foo' }, blockSettings, attributes ); + + expect( extraProps.className ).toBe( 'foo bar' ); + } ); + } ); + + describe( 'getHTMLRootElementClasses', () => { + it( 'should return an empty array if there are no classes', () => { + const classes = getHTMLRootElementClasses( '<div></div>' ); + + expect( classes ).toEqual( [] ); + } ); + + it( 'return an array of parsed classes from inner HTML', () => { + const classes = getHTMLRootElementClasses( '<div class=" foo bar "></div>' ); + + expect( classes ).toEqual( [ 'foo', 'bar' ] ); + } ); + } ); + + describe( 'addParsedDifference', () => { + const addParsedDifference = applyFilters.bind( null, 'blocks.getBlockAttributes' ); + + it( 'should do nothing if the block settings do not define custom className support', () => { + const attributes = addParsedDifference( + { className: 'foo' }, + { + ...blockSettings, + supports: { + customClassName: false, + }, + }, + '<div class="bar baz"></div>' + ); + + expect( attributes.className ).toBe( 'foo' ); + } ); + + it( 'should inject the className differences from parsed attributes', () => { + const attributes = addParsedDifference( + { className: 'foo' }, + blockSettings, + '<div class="default foo bar baz"></div>' + ); + + expect( attributes.className ).toBe( 'foo bar baz' ); + } ); + + it( 'should assign as undefined if there are no classes', () => { + const attributes = addParsedDifference( + {}, + blockSettings, + '<div class=""></div>' + ); + + expect( attributes.className ).toBeUndefined(); + } ); + + it( 'should add a custom class name to an element without a class', () => { + const attributes = addParsedDifference( + {}, + blockSettings, + '<div class="default foo"></div>' + ); + + expect( attributes.className ).toBe( 'foo' ); + } ); + + it( 'should remove the custom class and retain default class', () => { + const attributes = addParsedDifference( + { className: 'custom1 custom2' }, + blockSettings, + '<div class="default custom1"></div>' + ); + + expect( attributes.className ).toBe( 'custom1' ); + } ); + + it( 'should remove the custom class from an element originally without a class', () => { + const attributes = addParsedDifference( + { className: 'foo' }, + blockSettings, + '<div></div>' + ); + + expect( attributes.className ).toBeUndefined(); + } ); + + it( 'should remove the custom classes and retain default and other custom class', () => { + const attributes = addParsedDifference( + { className: 'custom1 custom2 custom3' }, + blockSettings, + '<div class="default custom1 custom3"></div>' + ); + + expect( attributes.className ).toBe( 'custom1 custom3' ); + } ); + + it( 'should not remove the custom classes for dynamic blocks', () => { + const attributes = addParsedDifference( + { className: 'custom1' }, + dynamicBlockSettings, + null, + ); + + expect( attributes.className ).toBe( 'custom1' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/hooks/test/generated-class-name.js b/gb-src/packages/block-editor/src/hooks/test/generated-class-name.js new file mode 100644 index 0000000000000..397ec9ab333ce --- /dev/null +++ b/gb-src/packages/block-editor/src/hooks/test/generated-class-name.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import '../generated-class-name'; + +describe( 'generated className', () => { + const blockSettings = { + name: 'chicken/ribs', + save: noop, + category: 'common', + title: 'block title', + }; + + describe( 'addSaveProps', () => { + const addSaveProps = applyFilters.bind( null, 'blocks.getSaveContent.extraProps' ); + + it( 'should do nothing if the block settings do not define generated className support', () => { + const attributes = { className: 'foo' }; + const extraProps = addSaveProps( {}, { + ...blockSettings, + supports: { + className: false, + }, + }, attributes ); + + expect( extraProps ).not.toHaveProperty( 'className' ); + } ); + + it( 'should inject the generated className', () => { + const attributes = { className: 'bar' }; + const extraProps = addSaveProps( { className: 'foo' }, blockSettings, attributes ); + + expect( extraProps.className ).toBe( 'wp-block-chicken-ribs foo' ); + } ); + + it( 'should not inject duplicates into className', () => { + const attributes = { className: 'bar' }; + const extraProps = addSaveProps( { className: 'foo wp-block-chicken-ribs' }, blockSettings, attributes ); + + expect( extraProps.className ).toBe( 'wp-block-chicken-ribs foo' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/index.js b/gb-src/packages/block-editor/src/index.js new file mode 100644 index 0000000000000..85b2af41e2e06 --- /dev/null +++ b/gb-src/packages/block-editor/src/index.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import '@wordpress/blocks'; +import '@wordpress/rich-text'; +import '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import './hooks'; +export * from './components'; +export * from './utils'; +export { storeConfig } from './store'; +export { SETTINGS_DEFAULTS } from './store/defaults'; diff --git a/gb-src/packages/block-editor/src/store/actions.js b/gb-src/packages/block-editor/src/store/actions.js new file mode 100644 index 0000000000000..3c4c69b91d4b5 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/actions.js @@ -0,0 +1,779 @@ +/** + * External dependencies + */ +import { castArray, first, get, includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { select } from './controls'; + +/** + * Generator which will yield a default block insert action if there + * are no other blocks at the root of the editor. This generator should be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + */ +function* ensureDefaultBlock() { + const count = yield select( + 'core/block-editor', + 'getBlockCount', + ); + + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + if ( count === 0 ) { + yield insertDefaultBlock(); + } +} + +/** + * Returns an action object used in signalling that blocks state should be + * reset to the specified array of blocks, taking precedence over any other + * content reflected as an edit in state. + * + * @param {Array} blocks Array of blocks. + * + * @return {Object} Action object. + */ +export function resetBlocks( blocks ) { + return { + type: 'RESET_BLOCKS', + blocks, + }; +} + +/** + * Returns an action object used in signalling that blocks have been received. + * Unlike resetBlocks, these should be appended to the existing known set, not + * replacing. + * + * @param {Object[]} blocks Array of block objects. + * + * @return {Object} Action object. + */ +export function receiveBlocks( blocks ) { + return { + type: 'RECEIVE_BLOCKS', + blocks, + }; +} + +/** + * Returns an action object used in signalling that the block attributes with + * the specified client ID has been updated. + * + * @param {string} clientId Block client ID. + * @param {Object} attributes Block attributes to be merged. + * + * @return {Object} Action object. + */ +export function updateBlockAttributes( clientId, attributes ) { + return { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId, + attributes, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID has been updated. + * + * @param {string} clientId Block client ID. + * @param {Object} updates Block attributes to be merged. + * + * @return {Object} Action object. + */ +export function updateBlock( clientId, updates ) { + return { + type: 'UPDATE_BLOCK', + clientId, + updates, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID has been selected, optionally accepting a position + * value reflecting its selection directionality. An initialPosition of -1 + * reflects a reverse selection. + * + * @param {string} clientId Block client ID. + * @param {?number} initialPosition Optional initial position. Pass as -1 to + * reflect reverse selection. + * + * @return {Object} Action object. + */ +export function selectBlock( clientId, initialPosition = null ) { + return { + type: 'SELECT_BLOCK', + initialPosition, + clientId, + }; +} + +/** + * Yields action objects used in signalling that the block preceding the given + * clientId should be selected. + * + * @param {string} clientId Block client ID. + */ +export function* selectPreviousBlock( clientId ) { + const previousBlockClientId = yield select( + 'core/block-editor', + 'getPreviousBlockClientId', + clientId + ); + + if ( previousBlockClientId ) { + yield selectBlock( previousBlockClientId, -1 ); + } +} + +/** + * Yields action objects used in signalling that the block following the given + * clientId should be selected. + * + * @param {string} clientId Block client ID. + */ +export function* selectNextBlock( clientId ) { + const nextBlockClientId = yield select( + 'core/block-editor', + 'getNextBlockClientId', + clientId + ); + + if ( nextBlockClientId ) { + yield selectBlock( nextBlockClientId ); + } +} + +/** + * Returns an action object used in signalling that a block multi-selection has started. + * + * @return {Object} Action object. + */ +export function startMultiSelect() { + return { + type: 'START_MULTI_SELECT', + }; +} + +/** + * Returns an action object used in signalling that block multi-selection stopped. + * + * @return {Object} Action object. + */ +export function stopMultiSelect() { + return { + type: 'STOP_MULTI_SELECT', + }; +} + +/** + * Returns an action object used in signalling that block multi-selection changed. + * + * @param {string} start First block of the multi selection. + * @param {string} end Last block of the multiselection. + * + * @return {Object} Action object. + */ +export function multiSelect( start, end ) { + return { + type: 'MULTI_SELECT', + start, + end, + }; +} + +/** + * Returns an action object used in signalling that the block selection is cleared. + * + * @return {Object} Action object. + */ +export function clearSelectedBlock() { + return { + type: 'CLEAR_SELECTED_BLOCK', + }; +} + +/** + * Returns an action object that enables or disables block selection. + * + * @param {boolean} [isSelectionEnabled=true] Whether block selection should + * be enabled. + * + * @return {Object} Action object. + */ +export function toggleSelection( isSelectionEnabled = true ) { + return { + type: 'TOGGLE_SELECTION', + isSelectionEnabled, + }; +} + +function getBlocksWithDefaultStylesApplied( blocks, blockEditorSettings ) { + const preferredStyleVariations = get( + blockEditorSettings, + [ '__experimentalPreferredStyleVariations', 'value' ], + {} + ); + return blocks.map( ( block ) => { + const blockName = block.name; + if ( ! preferredStyleVariations[ blockName ] ) { + return block; + } + const className = get( block, [ 'attributes', 'className' ] ); + if ( includes( className, 'is-style-' ) ) { + return block; + } + const { attributes = {} } = block; + const blockStyle = preferredStyleVariations[ blockName ]; + return { + ...block, + attributes: { + ...attributes, + className: `${ ( className || '' ) } is-style-${ blockStyle }`.trim(), + }, + }; + } ); +} + +/** + * Returns an action object signalling that a blocks should be replaced with + * one or more replacement blocks. + * + * @param {(string|string[])} clientIds Block client ID(s) to replace. + * @param {(Object|Object[])} blocks Replacement block(s). + * @param {number} indexToSelect Index of replacement block to + * select. + * + * @yield {Object} Action object. + */ +export function* replaceBlocks( clientIds, blocks, indexToSelect ) { + clientIds = castArray( clientIds ); + blocks = getBlocksWithDefaultStylesApplied( + castArray( blocks ), + yield select( + 'core/block-editor', + 'getSettings', + ) + ); + const rootClientId = yield select( + 'core/block-editor', + 'getBlockRootClientId', + first( clientIds ) + ); + // Replace is valid if the new blocks can be inserted in the root block. + for ( let index = 0; index < blocks.length; index++ ) { + const block = blocks[ index ]; + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( ! canInsertBlock ) { + return; + } + } + yield { + type: 'REPLACE_BLOCKS', + clientIds, + blocks, + time: Date.now(), + indexToSelect, + }; + yield* ensureDefaultBlock(); +} + +/** + * Returns an action object signalling that a single block should be replaced + * with one or more replacement blocks. + * + * @param {(string|string[])} clientId Block client ID to replace. + * @param {(Object|Object[])} block Replacement block(s). + * + * @return {Object} Action object. + */ +export function replaceBlock( clientId, block ) { + return replaceBlocks( clientId, block ); +} + +/** + * Higher-order action creator which, given the action type to dispatch creates + * an action creator for managing block movement. + * + * @param {string} type Action type to dispatch. + * + * @return {Function} Action creator. + */ +function createOnMove( type ) { + return ( clientIds, rootClientId ) => { + return { + clientIds: castArray( clientIds ), + type, + rootClientId, + }; + }; +} + +export const moveBlocksDown = createOnMove( 'MOVE_BLOCKS_DOWN' ); +export const moveBlocksUp = createOnMove( 'MOVE_BLOCKS_UP' ); + +/** + * Returns an action object signalling that an indexed block should be moved + * to a new index. + * + * @param {?string} clientId The client ID of the block. + * @param {?string} fromRootClientId Root client ID source. + * @param {?string} toRootClientId Root client ID destination. + * @param {number} index The index to move the block into. + * + * @yield {Object} Action object. + */ +export function* moveBlockToPosition( clientId, fromRootClientId = '', toRootClientId = '', index ) { + const templateLock = yield select( + 'core/block-editor', + 'getTemplateLock', + fromRootClientId + ); + + // If locking is equal to all on the original clientId (fromRootClientId), + // it is not possible to move the block to any other position. + if ( templateLock === 'all' ) { + return; + } + + const action = { + type: 'MOVE_BLOCK_TO_POSITION', + fromRootClientId, + toRootClientId, + clientId, + index, + }; + // If moving inside the same root block the move is always possible. + if ( fromRootClientId === toRootClientId ) { + yield action; + return; + } + + // If templateLock is insert we can not remove the block from the parent. + // Given that here we know that we are moving the block to a different parent, the move should not be possible if the condition is true. + if ( templateLock === 'insert' ) { + return; + } + + const blockName = yield select( + 'core/block-editor', + 'getBlockName', + clientId + ); + + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + blockName, + toRootClientId + ); + + // If moving to other parent block, the move is possible if we can insert a block of the same type inside the new parent block. + if ( canInsertBlock ) { + yield action; + } +} + +/** + * Returns an action object used in signalling that a single block should be + * inserted, optionally at a specific index respective a root block list. + * + * @param {Object} block Block object to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootClientId Optional root client ID of block list on which to insert. + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function insertBlock( + block, + index, + rootClientId, + updateSelection = true, +) { + return insertBlocks( + [ block ], + index, + rootClientId, + updateSelection + ); +} + +/** + * Returns an action object used in signalling that an array of blocks should + * be inserted, optionally at a specific index respective a root block list. + * + * @param {Object[]} blocks Block objects to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootClientId Optional root client ID of block list on which to insert. + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function* insertBlocks( + blocks, + index, + rootClientId, + updateSelection = true +) { + blocks = getBlocksWithDefaultStylesApplied( + castArray( blocks ), + yield select( + 'core/block-editor', + 'getSettings', + ) + ); + const allowedBlocks = []; + for ( const block of blocks ) { + const isValid = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( isValid ) { + allowedBlocks.push( block ); + } + } + if ( allowedBlocks.length ) { + return { + type: 'INSERT_BLOCKS', + blocks: allowedBlocks, + index, + rootClientId, + time: Date.now(), + updateSelection, + }; + } +} + +/** + * Returns an action object used in signalling that the insertion point should + * be shown. + * + * @param {?string} rootClientId Optional root client ID of block list on + * which to insert. + * @param {?number} index Index at which block should be inserted. + * + * @return {Object} Action object. + */ +export function showInsertionPoint( rootClientId, index ) { + return { + type: 'SHOW_INSERTION_POINT', + rootClientId, + index, + }; +} + +/** + * Returns an action object hiding the insertion point. + * + * @return {Object} Action object. + */ +export function hideInsertionPoint() { + return { + type: 'HIDE_INSERTION_POINT', + }; +} + +/** + * Returns an action object resetting the template validity. + * + * @param {boolean} isValid template validity flag. + * + * @return {Object} Action object. + */ +export function setTemplateValidity( isValid ) { + return { + type: 'SET_TEMPLATE_VALIDITY', + isValid, + }; +} + +/** + * Returns an action object synchronize the template with the list of blocks + * + * @return {Object} Action object. + */ +export function synchronizeTemplate() { + return { + type: 'SYNCHRONIZE_TEMPLATE', + }; +} + +/** + * Returns an action object used in signalling that two blocks should be merged + * + * @param {string} firstBlockClientId Client ID of the first block to merge. + * @param {string} secondBlockClientId Client ID of the second block to merge. + * + * @return {Object} Action object. + */ +export function mergeBlocks( firstBlockClientId, secondBlockClientId ) { + return { + type: 'MERGE_BLOCKS', + blocks: [ firstBlockClientId, secondBlockClientId ], + }; +} + +/** + * Yields action objects used in signalling that the blocks corresponding to + * the set of specified client IDs are to be removed. + * + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block should be + * selected when a block is removed. + */ +export function* removeBlocks( clientIds, selectPrevious = true ) { + clientIds = castArray( clientIds ); + + if ( selectPrevious ) { + yield selectPreviousBlock( clientIds[ 0 ] ); + } + + yield { + type: 'REMOVE_BLOCKS', + clientIds, + }; + + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + yield* ensureDefaultBlock(); +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID is to be removed. + * + * @param {string} clientId Client ID of block to remove. + * @param {boolean} selectPrevious True if the previous block should be + * selected when a block is removed. + * + * @return {Object} Action object. + */ +export function removeBlock( clientId, selectPrevious ) { + return removeBlocks( [ clientId ], selectPrevious ); +} + +/** + * Returns an action object used in signalling that the inner blocks with the + * specified client ID should be replaced. + * + * @param {string} rootClientId Client ID of the block whose InnerBlocks will re replaced. + * @param {Object[]} blocks Block objects to insert as new InnerBlocks + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function replaceInnerBlocks( rootClientId, blocks, updateSelection = true ) { + return { + type: 'REPLACE_INNER_BLOCKS', + rootClientId, + blocks, + updateSelection, + time: Date.now(), + }; +} + +/** + * Returns an action object used to toggle the block editing mode between + * visual and HTML modes. + * + * @param {string} clientId Block client ID. + * + * @return {Object} Action object. + */ +export function toggleBlockMode( clientId ) { + return { + type: 'TOGGLE_BLOCK_MODE', + clientId, + }; +} + +/** + * Returns an action object used in signalling that the user has begun to type. + * + * @return {Object} Action object. + */ +export function startTyping() { + return { + type: 'START_TYPING', + }; +} + +/** + * Returns an action object used in signalling that the user has stopped typing. + * + * @return {Object} Action object. + */ +export function stopTyping() { + return { + type: 'STOP_TYPING', + }; +} + +/** + * Returns an action object used in signalling that the caret has entered formatted text. + * + * @return {Object} Action object. + */ +export function enterFormattedText() { + return { + type: 'ENTER_FORMATTED_TEXT', + }; +} + +/** + * Returns an action object used in signalling that the user caret has exited formatted text. + * + * @return {Object} Action object. + */ +export function exitFormattedText() { + return { + type: 'EXIT_FORMATTED_TEXT', + }; +} + +/** + * Returns an action object used in signalling that the user caret has changed + * position. + * + * @param {string} clientId The selected block client ID. + * @param {string} attributeKey The selected block attribute key. + * @param {number} startOffset The start offset. + * @param {number} endOffset The end offset. + * + * @return {Object} Action object. + */ +export function selectionChange( clientId, attributeKey, startOffset, endOffset ) { + return { + type: 'SELECTION_CHANGE', + clientId, + attributeKey, + startOffset, + endOffset, + }; +} + +/** + * Returns an action object used in signalling that a new block of the default + * type should be added to the block list. + * + * @param {?Object} attributes Optional attributes of the block to assign. + * @param {?string} rootClientId Optional root client ID of block list on which + * to append. + * @param {?number} index Optional index where to insert the default block + * + * @return {Object} Action object + */ +export function insertDefaultBlock( attributes, rootClientId, index ) { + // Abort if there is no default block type (if it has been unregistered). + const defaultBlockName = getDefaultBlockName(); + if ( ! defaultBlockName ) { + return; + } + + const block = createBlock( defaultBlockName, attributes ); + + return insertBlock( block, index, rootClientId ); +} + +/** + * Returns an action object that changes the nested settings of a given block. + * + * @param {string} clientId Client ID of the block whose nested setting are + * being received. + * @param {Object} settings Object with the new settings for the nested block. + * + * @return {Object} Action object + */ +export function updateBlockListSettings( clientId, settings ) { + return { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId, + settings, + }; +} + +/* + * Returns an action object used in signalling that the block editor settings have been updated. + * + * @param {Object} settings Updated settings + * + * @return {Object} Action object + */ +export function updateSettings( settings ) { + return { + type: 'UPDATE_SETTINGS', + settings, + }; +} + +/** + * Returns an action object used in signalling that a temporary reusable blocks have been saved + * in order to switch its temporary id with the real id. + * + * @param {string} id Reusable block's id. + * @param {string} updatedId Updated block's id. + * + * @return {Object} Action object. + */ +export function __unstableSaveReusableBlock( id, updatedId ) { + return { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id, + updatedId, + }; +} + +/** + * Returns an action object used in signalling that the last block change should be marked explicitely as persistent. + * + * @return {Object} Action object. + */ +export function __unstableMarkLastChangeAsPersistent() { + return { type: 'MARK_LAST_CHANGE_AS_PERSISTENT' }; +} + +/** + * Returns an action object used in signalling that the last block change is + * an automatic change, meaning it was not performed by the user, and can be + * undone using the `Escape` and `Backspace` keys. This action must be called + * after the change was made, and any actions that are a consequence of it, so + * it is recommended to be called at the next idle period to ensure all + * selection changes have been recorded. + * + * @return {Object} Action object. + */ +export function __unstableMarkAutomaticChange() { + return { type: 'MARK_AUTOMATIC_CHANGE' }; +} + +/** + * Returns an action object used to enable or disable the navigation mode. + * + * @param {string} isNavigationMode Enable/Disable navigation mode. + * + * @return {Object} Action object + */ +export function setNavigationMode( isNavigationMode = true ) { + return { + type: 'SET_NAVIGATION_MODE', + isNavigationMode, + }; +} diff --git a/gb-src/packages/block-editor/src/store/array.js b/gb-src/packages/block-editor/src/store/array.js new file mode 100644 index 0000000000000..176d8936450af --- /dev/null +++ b/gb-src/packages/block-editor/src/store/array.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * Insert one or multiple elements into a given position of an array. + * + * @param {Array} array Source array. + * @param {*} elements Elements to insert. + * @param {number} index Insert Position. + * + * @return {Array} Result. + */ +export function insertAt( array, elements, index ) { + return [ + ...array.slice( 0, index ), + ...castArray( elements ), + ...array.slice( index ), + ]; +} + +/** + * Moves an element in an array. + * + * @param {Array} array Source array. + * @param {number} from Source index. + * @param {number} to Destination index. + * @param {number} count Number of elements to move. + * + * @return {Array} Result. + */ +export function moveTo( array, from, to, count = 1 ) { + const withoutMovedElements = [ ...array ]; + withoutMovedElements.splice( from, count ); + return insertAt( + withoutMovedElements, + array.slice( from, from + count ), + to, + ); +} diff --git a/gb-src/packages/block-editor/src/store/controls.js b/gb-src/packages/block-editor/src/store/controls.js new file mode 100644 index 0000000000000..5012ab244c21c --- /dev/null +++ b/gb-src/packages/block-editor/src/store/controls.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; + +/** + * Calls a selector using the current state. + * + * @param {string} storeName Store name. + * @param {string} selectorName Selector name. + * @param {Array} args Selector arguments. + * + * @return {Object} control descriptor. + */ +export function select( storeName, selectorName, ...args ) { + return { + type: 'SELECT', + storeName, + selectorName, + args, + }; +} + +const controls = { + SELECT: createRegistryControl( ( registry ) => ( { storeName, selectorName, args } ) => { + return registry.select( storeName )[ selectorName ]( ...args ); + } ), +}; + +export default controls; diff --git a/gb-src/packages/block-editor/src/store/defaults.js b/gb-src/packages/block-editor/src/store/defaults.js new file mode 100644 index 0000000000000..f7076f28c2757 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/defaults.js @@ -0,0 +1,156 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +export const PREFERENCES_DEFAULTS = { + insertUsage: {}, +}; + +/** + * The default editor settings + * + * alignWide boolean Enable/Disable Wide/Full Alignments + * availableLegacyWidgets Array Array of objects representing the legacy widgets available. + * colors Array Palette colors + * disableCustomColors boolean Whether or not the custom colors are disabled + * fontSizes Array Available font sizes + * disableCustomFontSizes boolean Whether or not the custom font sizes are disabled + * imageSizes Array Available image sizes + * maxWidth number Max width to constraint resizing + * allowedBlockTypes boolean|Array Allowed block types + * hasFixedToolbar boolean Whether or not the editor toolbar is fixed + * hasPermissionsToManageWidgets boolean Whether or not the user is able to manage widgets. + * focusMode boolean Whether the focus mode is enabled or not + * styles Array Editor Styles + * isRTL boolean Whether the editor is in RTL mode + * bodyPlaceholder string Empty post placeholder + * titlePlaceholder string Empty title placeholder + * codeEditingEnabled string Whether or not the user can switch to the code editor + * showInserterHelpPanel boolean Whether or not the inserter help panel is shown + * __experimentalCanUserUseUnfilteredHTML string Whether the user should be able to use unfiltered HTML or the HTML should be filtered e.g., to remove elements considered insecure like iframes. + * __experimentalEnableLegacyWidgetBlock boolean Whether the user has enabled the Legacy Widget Block + * __experimentalEnableMenuBlock boolean Whether the user has enabled the Menu Block + * __experimentalBlockDirectory boolean Whether the user has enabled the Block Directory + */ +export const SETTINGS_DEFAULTS = { + alignWide: false, + colors: [ + { + name: __( 'Pale pink' ), + slug: 'pale-pink', + color: '#f78da7', + }, + { name: __( 'Vivid red' ), + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: __( 'Luminous vivid orange' ), + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: __( 'Luminous vivid amber' ), + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + { + name: __( 'Light green cyan' ), + slug: 'light-green-cyan', + color: '#7bdcb5', + }, + { + name: __( 'Vivid green cyan' ), + slug: 'vivid-green-cyan', + color: '#00d084', + }, + { + name: __( 'Pale cyan blue' ), + slug: 'pale-cyan-blue', + color: '#8ed1fc', + }, + { + name: __( 'Vivid cyan blue' ), + slug: 'vivid-cyan-blue', + color: '#0693e3', + }, + { + name: __( 'Vivid purple' ), + slug: 'vivid-purple', + color: '#9b51e0', + }, + { + name: __( 'Very light gray' ), + slug: 'very-light-gray', + color: '#eeeeee', + }, + { + name: __( 'Cyan bluish gray' ), + slug: 'cyan-bluish-gray', + color: '#abb8c3', + }, + { + name: __( 'Very dark gray' ), + slug: 'very-dark-gray', + color: '#313131', + }, + ], + + fontSizes: [ + { + name: _x( 'Small', 'font size name' ), + size: 13, + slug: 'small', + }, + { + name: _x( 'Normal', 'font size name' ), + size: 16, + slug: 'normal', + }, + { + name: _x( 'Medium', 'font size name' ), + size: 20, + slug: 'medium', + }, + { + name: _x( 'Large', 'font size name' ), + size: 36, + slug: 'large', + }, + { + name: _x( 'Huge', 'font size name' ), + size: 48, + slug: 'huge', + }, + ], + + imageSizes: [ + { slug: 'thumbnail', label: __( 'Thumbnail' ) }, + { slug: 'medium', label: __( 'Medium' ) }, + { slug: 'large', label: __( 'Large' ) }, + { slug: 'full', label: __( 'Full Size' ) }, + ], + + // This is current max width of the block inner area + // It's used to constraint image resizing and this value could be overridden later by themes + maxWidth: 580, + + // Allowed block types for the editor, defaulting to true (all supported). + allowedBlockTypes: true, + + // Maximum upload size in bytes allowed for the site. + maxUploadFileSize: 0, + + // List of allowed mime types and file extensions. + allowedMimeTypes: null, + + availableLegacyWidgets: {}, + hasPermissionsToManageWidgets: false, + showInserterHelpPanel: true, + __experimentalCanUserUseUnfilteredHTML: false, + __experimentalEnableLegacyWidgetBlock: false, + __experimentalEnableMenuBlock: false, + __experimentalBlockDirectory: false, +}; + diff --git a/gb-src/packages/block-editor/src/store/effects.js b/gb-src/packages/block-editor/src/store/effects.js new file mode 100644 index 0000000000000..024aaf054177c --- /dev/null +++ b/gb-src/packages/block-editor/src/store/effects.js @@ -0,0 +1,197 @@ +/** + * External dependencies + */ +import { findKey } from 'lodash'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { + getBlockType, + doBlocksMatchTemplate, + switchToBlockType, + synchronizeBlocksWithTemplate, + cloneBlock, +} from '@wordpress/blocks'; +import { _n, sprintf } from '@wordpress/i18n'; +import { create, toHTMLString, insert, remove } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { + replaceBlocks, + selectBlock, + setTemplateValidity, + resetBlocks, + selectionChange, +} from './actions'; +import { + getBlock, + getBlocks, + getSelectedBlockCount, + getTemplateLock, + getTemplate, + isValidTemplate, + getSelectionStart, +} from './selectors'; + +/** + * Block validity is a function of blocks state (at the point of a + * reset) and the template setting. As a compromise to its placement + * across distinct parts of state, it is implemented here as a side- + * effect of the block reset action. + * + * @param {Object} action RESET_BLOCKS action. + * @param {Object} store Store instance. + * + * @return {?Object} New validity set action if validity has changed. + */ +export function validateBlocksToTemplate( action, store ) { + const state = store.getState(); + const template = getTemplate( state ); + const templateLock = getTemplateLock( state ); + + // Unlocked templates are considered always valid because they act + // as default values only. + const isBlocksValidToTemplate = ( + ! template || + templateLock !== 'all' || + doBlocksMatchTemplate( action.blocks, template ) + ); + + // Update if validity has changed. + if ( isBlocksValidToTemplate !== isValidTemplate( state ) ) { + return setTemplateValidity( isBlocksValidToTemplate ); + } +} + +export default { + MERGE_BLOCKS( action, store ) { + const { dispatch } = store; + const state = store.getState(); + const [ clientIdA, clientIdB ] = action.blocks; + const blockA = getBlock( state, clientIdA ); + const blockAType = getBlockType( blockA.name ); + + // Only focus the previous block if it's not mergeable + if ( ! blockAType.merge ) { + dispatch( selectBlock( blockA.clientId ) ); + return; + } + + const blockB = getBlock( state, clientIdB ); + const blockBType = getBlockType( blockB.name ); + const { clientId, attributeKey, offset } = getSelectionStart( state ); + const hasTextSelection = ( + ( clientId === clientIdA || clientId === clientIdB ) && + attributeKey !== undefined && + offset !== undefined + ); + + // A robust way to retain selection position through various transforms + // is to insert a special character at the position and then recover it. + const START_OF_SELECTED_AREA = '\u0086'; + + // Clone the blocks so we don't insert the character in a "live" block. + const cloneA = cloneBlock( blockA ); + const cloneB = cloneBlock( blockB ); + + if ( hasTextSelection ) { + const selectedBlock = clientId === clientIdA ? cloneA : cloneB; + const html = selectedBlock.attributes[ attributeKey ]; + const selectedBlockType = clientId === clientIdA ? blockAType : blockBType; + const { + multiline: multilineTag, + __unstableMultilineWrapperTags: multilineWrapperTags, + } = selectedBlockType.attributes[ attributeKey ]; + const value = insert( create( { + html, + multilineTag, + multilineWrapperTags, + } ), START_OF_SELECTED_AREA, offset, offset ); + + selectedBlock.attributes[ attributeKey ] = toHTMLString( { + value, + multilineTag, + } ); + } + + // We can only merge blocks with similar types + // thus, we transform the block to merge first + const blocksWithTheSameType = blockA.name === blockB.name ? + [ cloneB ] : + switchToBlockType( cloneB, blockA.name ); + + // If the block types can not match, do nothing + if ( ! blocksWithTheSameType || ! blocksWithTheSameType.length ) { + return; + } + + // Calling the merge to update the attributes and remove the block to be merged + const updatedAttributes = blockAType.merge( + cloneA.attributes, + blocksWithTheSameType[ 0 ].attributes + ); + + if ( hasTextSelection ) { + const newAttributeKey = findKey( updatedAttributes, ( v ) => + typeof v === 'string' && v.indexOf( START_OF_SELECTED_AREA ) !== -1 + ); + const convertedHtml = updatedAttributes[ newAttributeKey ]; + const { + multiline: multilineTag, + __unstableMultilineWrapperTags: multilineWrapperTags, + } = blockAType.attributes[ newAttributeKey ]; + const convertedValue = create( { + html: convertedHtml, + multilineTag, + multilineWrapperTags, + } ); + const newOffset = convertedValue.text.indexOf( START_OF_SELECTED_AREA ); + const newValue = remove( convertedValue, newOffset, newOffset + 1 ); + const newHtml = toHTMLString( { value: newValue, multilineTag } ); + + updatedAttributes[ newAttributeKey ] = newHtml; + + dispatch( selectionChange( + blockA.clientId, + newAttributeKey, + newOffset, + newOffset + ) ); + } + + dispatch( replaceBlocks( + [ blockA.clientId, blockB.clientId ], + [ + { + ...blockA, + attributes: { + ...blockA.attributes, + ...updatedAttributes, + }, + }, + ...blocksWithTheSameType.slice( 1 ), + ] + ) ); + }, + RESET_BLOCKS: [ + validateBlocksToTemplate, + ], + MULTI_SELECT: ( action, { getState } ) => { + const blockCount = getSelectedBlockCount( getState() ); + + /* translators: %s: number of selected blocks */ + speak( sprintf( _n( '%s block selected.', '%s blocks selected.', blockCount ), blockCount ), 'assertive' ); + }, + SYNCHRONIZE_TEMPLATE( action, { getState } ) { + const state = getState(); + const blocks = getBlocks( state ); + const template = getTemplate( state ); + const updatedBlockList = synchronizeBlocksWithTemplate( blocks, template ); + + return resetBlocks( updatedBlockList ); + }, +}; diff --git a/gb-src/packages/block-editor/src/store/index.js b/gb-src/packages/block-editor/src/store/index.js new file mode 100644 index 0000000000000..bfc7766a50876 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/index.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import applyMiddlewares from './middlewares'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import controls from './controls'; + +/** + * Module Constants + */ +const MODULE_KEY = 'core/block-editor'; + +/** + * Block editor data store configuration. + * + * @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore + * + * @type {Object} + */ +export const storeConfig = { + reducer, + selectors, + actions, + controls, +}; + +const store = registerStore( MODULE_KEY, { + ...storeConfig, + persist: [ 'preferences' ], +} ); +applyMiddlewares( store ); + +export default store; diff --git a/gb-src/packages/block-editor/src/store/middlewares.js b/gb-src/packages/block-editor/src/store/middlewares.js new file mode 100644 index 0000000000000..6381132bb81e0 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/middlewares.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import refx from 'refx'; +import multi from 'redux-multi'; +import { flowRight } from 'lodash'; + +/** + * Internal dependencies + */ +import effects from './effects'; + +/** + * Applies the custom middlewares used specifically in the editor module. + * + * @param {Object} store Store Object. + * + * @return {Object} Update Store Object. + */ +function applyMiddlewares( store ) { + const middlewares = [ + refx( effects ), + multi, + ]; + + let enhancedDispatch = () => { + throw new Error( + 'Dispatching while constructing your middleware is not allowed. ' + + 'Other middleware would not be applied to this dispatch.' + ); + }; + let chain = []; + + const middlewareAPI = { + getState: store.getState, + dispatch: ( ...args ) => enhancedDispatch( ...args ), + }; + chain = middlewares.map( ( middleware ) => middleware( middlewareAPI ) ); + enhancedDispatch = flowRight( ...chain )( store.dispatch ); + + store.dispatch = enhancedDispatch; + return store; +} + +export default applyMiddlewares; diff --git a/gb-src/packages/block-editor/src/store/reducer.js b/gb-src/packages/block-editor/src/store/reducer.js new file mode 100644 index 0000000000000..9f40684c6632d --- /dev/null +++ b/gb-src/packages/block-editor/src/store/reducer.js @@ -0,0 +1,1302 @@ +/** + * External dependencies + */ +import { + flow, + reduce, + first, + last, + omit, + without, + mapValues, + keys, + isEqual, + isEmpty, + get, + identity, + difference, + omitBy, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; +import { isReusableBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + PREFERENCES_DEFAULTS, + SETTINGS_DEFAULTS, +} from './defaults'; +import { insertAt, moveTo } from './array'; + +/** + * Given an array of blocks, returns an object where each key is a nesting + * context, the value of which is an array of block client IDs existing within + * that nesting context. + * + * @param {Array} blocks Blocks to map. + * @param {?string} rootClientId Assumed root client ID. + * + * @return {Object} Block order map object. + */ +function mapBlockOrder( blocks, rootClientId = '' ) { + const result = { [ rootClientId ]: [] }; + + blocks.forEach( ( block ) => { + const { clientId, innerBlocks } = block; + + result[ rootClientId ].push( clientId ); + + Object.assign( result, mapBlockOrder( innerBlocks, clientId ) ); + } ); + + return result; +} + +/** + * Given an array of blocks, returns an object where each key contains + * the clientId of the block and the value is the parent of the block. + * + * @param {Array} blocks Blocks to map. + * @param {?string} rootClientId Assumed root client ID. + * + * @return {Object} Block order map object. + */ +function mapBlockParents( blocks, rootClientId = '' ) { + return blocks.reduce( ( result, block ) => Object.assign( + result, + { [ block.clientId ]: rootClientId }, + mapBlockParents( block.innerBlocks, block.clientId ) + ), {} ); +} + +/** + * Helper method to iterate through all blocks, recursing into inner blocks, + * applying a transformation function to each one. + * Returns a flattened object with the transformed blocks. + * + * @param {Array} blocks Blocks to flatten. + * @param {Function} transform Transforming function to be applied to each block. + * + * @return {Object} Flattened object. + */ +function flattenBlocks( blocks, transform = identity ) { + const result = {}; + + const stack = [ ...blocks ]; + while ( stack.length ) { + const { innerBlocks, ...block } = stack.shift(); + stack.push( ...innerBlocks ); + result[ block.clientId ] = transform( block ); + } + + return result; +} + +/** + * Given an array of blocks, returns an object containing all blocks, without + * attributes, recursing into inner blocks. Keys correspond to the block client + * ID, the value of which is the attributes object. + * + * @param {Array} blocks Blocks to flatten. + * + * @return {Object} Flattened block attributes object. + */ +function getFlattenedBlocksWithoutAttributes( blocks ) { + return flattenBlocks( blocks, ( block ) => omit( block, 'attributes' ) ); +} + +/** + * Given an array of blocks, returns an object containing all block attributes, + * recursing into inner blocks. Keys correspond to the block client ID, the + * value of which is the attributes object. + * + * @param {Array} blocks Blocks to flatten. + * + * @return {Object} Flattened block attributes object. + */ +function getFlattenedBlockAttributes( blocks ) { + return flattenBlocks( blocks, ( block ) => block.attributes ); +} + +/** + * Given a block order map object, returns *all* of the block client IDs that are + * a descendant of the given root client ID. + * + * Calling this with `rootClientId` set to `''` results in a list of client IDs + * that are in the post. That is, it excludes blocks like fetched reusable + * blocks which are stored into state but not visible. + * + * @param {Object} blocksOrder Object that maps block client IDs to a list of + * nested block client IDs. + * @param {?string} rootClientId The root client ID to search. Defaults to ''. + * + * @return {Array} List of descendant client IDs. + */ +function getNestedBlockClientIds( blocksOrder, rootClientId = '' ) { + return reduce( blocksOrder[ rootClientId ], ( result, clientId ) => [ + ...result, + clientId, + ...getNestedBlockClientIds( blocksOrder, clientId ), + ], [] ); +} + +/** + * Returns an object against which it is safe to perform mutating operations, + * given the original object and its current working copy. + * + * @param {Object} original Original object. + * @param {Object} working Working object. + * + * @return {Object} Mutation-safe object. + */ +function getMutateSafeObject( original, working ) { + if ( original === working ) { + return { ...original }; + } + + return working; +} + +/** + * Returns true if the two object arguments have the same keys, or false + * otherwise. + * + * @param {Object} a First object. + * @param {Object} b Second object. + * + * @return {boolean} Whether the two objects have the same keys. + */ +export function hasSameKeys( a, b ) { + return isEqual( keys( a ), keys( b ) ); +} + +/** + * Returns true if, given the currently dispatching action and the previously + * dispatched action, the two actions are updating the same block attribute, or + * false otherwise. + * + * @param {Object} action Currently dispatching action. + * @param {Object} lastAction Previously dispatched action. + * + * @return {boolean} Whether actions are updating the same block attribute. + */ +export function isUpdatingSameBlockAttribute( action, lastAction ) { + return ( + action.type === 'UPDATE_BLOCK_ATTRIBUTES' && + lastAction !== undefined && + lastAction.type === 'UPDATE_BLOCK_ATTRIBUTES' && + action.clientId === lastAction.clientId && + hasSameKeys( action.attributes, lastAction.attributes ) + ); +} + +/** + * Utility returning an object with an empty object value for each key. + * + * @param {Array} objectKeys Keys to fill. + * @return {Object} Object filled with empty object as values for each clientId. + */ +const fillKeysWithEmptyObject = ( objectKeys ) => { + return objectKeys.reduce( ( result, key ) => { + result[ key ] = {}; + return result; + }, {} ); +}; + +/** + * Higher-order reducer intended to compute a cache key for each block in the post. + * A new instance of the cache key (empty object) is created each time the block object + * needs to be refreshed (for any change in the block or its children). + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withBlockCache = ( reducer ) => ( state = {}, action ) => { + const newState = reducer( state, action ); + + if ( newState === state ) { + return state; + } + newState.cache = state.cache ? state.cache : {}; + + /** + * For each clientId provided, traverses up parents, adding the provided clientIds + * and each parent's clientId to the returned array. + * + * When calling this function consider that it uses the old state, so any state + * modifications made by the `reducer` will not be present. + * + * @param {Array} clientIds an Array of block clientIds. + * + * @return {Array} The provided clientIds and all of their parent clientIds. + */ + const getBlocksWithParentsClientIds = ( clientIds ) => { + return clientIds.reduce( ( result, clientId ) => { + let current = clientId; + do { + result.push( current ); + current = state.parents[ current ]; + } while ( current ); + return result; + }, [] ); + }; + + switch ( action.type ) { + case 'RESET_BLOCKS': + newState.cache = mapValues( flattenBlocks( action.blocks ), () => ( {} ) ); + break; + case 'RECEIVE_BLOCKS': + case 'INSERT_BLOCKS': { + const updatedBlockUids = keys( flattenBlocks( action.blocks ) ); + if ( action.rootClientId ) { + updatedBlockUids.push( action.rootClientId ); + } + newState.cache = { + ...newState.cache, + ...fillKeysWithEmptyObject( + getBlocksWithParentsClientIds( updatedBlockUids ), + ), + }; + break; + } + case 'UPDATE_BLOCK': + case 'UPDATE_BLOCK_ATTRIBUTES': + newState.cache = { + ...newState.cache, + ...fillKeysWithEmptyObject( + getBlocksWithParentsClientIds( [ action.clientId ] ), + ), + }; + break; + case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': + const parentClientIds = fillKeysWithEmptyObject( getBlocksWithParentsClientIds( action.replacedClientIds ) ); + + newState.cache = { + ...omit( newState.cache, action.replacedClientIds ), + ...omit( parentClientIds, action.replacedClientIds ), + ...fillKeysWithEmptyObject( keys( flattenBlocks( action.blocks ) ) ), + }; + break; + case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': + newState.cache = { + ...omit( newState.cache, action.removedClientIds ), + ...fillKeysWithEmptyObject( + difference( getBlocksWithParentsClientIds( action.clientIds ), action.clientIds ), + ), + }; + break; + case 'MOVE_BLOCK_TO_POSITION': { + const updatedBlockUids = [ action.clientId ]; + if ( action.fromRootClientId ) { + updatedBlockUids.push( action.fromRootClientId ); + } + if ( action.toRootClientId ) { + updatedBlockUids.push( action.toRootClientId ); + } + newState.cache = { + ...newState.cache, + ...fillKeysWithEmptyObject( + getBlocksWithParentsClientIds( updatedBlockUids ) + ), + }; + break; + } + case 'MOVE_BLOCKS_UP': + case 'MOVE_BLOCKS_DOWN': { + const updatedBlockUids = []; + if ( action.rootClientId ) { + updatedBlockUids.push( action.rootClientId ); + } + newState.cache = { + ...newState.cache, + ...fillKeysWithEmptyObject( + getBlocksWithParentsClientIds( updatedBlockUids ) + ), + }; + break; + } + case 'SAVE_REUSABLE_BLOCK_SUCCESS': { + const updatedBlockUids = keys( omitBy( newState.attributes, ( attributes, clientId ) => { + return newState.byClientId[ clientId ].name !== 'core/block' || attributes.ref !== action.updatedId; + } ) ); + + newState.cache = { + ...newState.cache, + ...fillKeysWithEmptyObject( + getBlocksWithParentsClientIds( updatedBlockUids ) + ), + }; + } + } + + return newState; +}; + +/** + * Higher-order reducer intended to augment the blocks reducer, assigning an + * `isPersistentChange` property value corresponding to whether a change in + * state can be considered as persistent. All changes are considered persistent + * except when updating the same block attribute as in the previous action. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +function withPersistentBlockChange( reducer ) { + let lastAction; + + return ( state, action ) => { + let nextState = reducer( state, action ); + + const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT'; + + // Defer to previous state value (or default) unless changing or + // explicitly marking as persistent. + if ( state === nextState && ! isExplicitPersistentChange ) { + const nextIsPersistentChange = get( state, [ 'isPersistentChange' ], true ); + if ( state.isPersistentChange === nextIsPersistentChange ) { + return state; + } + + return { + ...nextState, + isPersistentChange: nextIsPersistentChange, + }; + } + + nextState = { + ...nextState, + isPersistentChange: ( + isExplicitPersistentChange || + ! isUpdatingSameBlockAttribute( action, lastAction ) + ), + }; + + // In comparing against the previous action, consider only those which + // would have qualified as one which would have been ignored or not + // have resulted in a changed state. + lastAction = action; + + return nextState; + }; +} + +/** + * Higher-order reducer intended to augment the blocks reducer, assigning an + * `isIgnoredChange` property value corresponding to whether a change in state + * can be considered as ignored. A change is considered ignored when the result + * of an action not incurred by direct user interaction. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +function withIgnoredBlockChange( reducer ) { + /** + * Set of action types for which a blocks state change should be ignored. + * + * @type {Set} + */ + const IGNORED_ACTION_TYPES = new Set( [ + 'RECEIVE_BLOCKS', + ] ); + + return ( state, action ) => { + const nextState = reducer( state, action ); + + if ( nextState !== state ) { + nextState.isIgnoredChange = IGNORED_ACTION_TYPES.has( action.type ); + } + + return nextState; + }; +} + +/** + * Higher-order reducer targeting the combined blocks reducer, augmenting + * block client IDs in remove action to include cascade of inner blocks. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => { + const getAllChildren = ( clientIds ) => { + let result = clientIds; + for ( let i = 0; i < result.length; i++ ) { + if ( ! state.order[ result[ i ] ] ) { + continue; + } + + if ( result === clientIds ) { + result = [ ...result ]; + } + + result.push( ...state.order[ result[ i ] ] ); + } + return result; + }; + + if ( state ) { + switch ( action.type ) { + case 'REMOVE_BLOCKS': + action = { + ...action, + type: 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN', + removedClientIds: getAllChildren( action.clientIds ), + }; + break; + case 'REPLACE_BLOCKS': + action = { + ...action, + type: 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN', + replacedClientIds: getAllChildren( action.clientIds ), + }; + break; + } + } + + return reducer( state, action ); +}; + +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `RESET_BLOCKS` action. When dispatched, this action will replace all + * blocks that exist in the post, leaving blocks that exist only in state (e.g. + * reusable blocks) alone. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withBlockReset = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'RESET_BLOCKS' ) { + const visibleClientIds = getNestedBlockClientIds( state.order ); + return { + ...state, + byClientId: { + ...omit( state.byClientId, visibleClientIds ), + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }, + attributes: { + ...omit( state.attributes, visibleClientIds ), + ...getFlattenedBlockAttributes( action.blocks ), + }, + order: { + ...omit( state.order, visibleClientIds ), + ...mapBlockOrder( action.blocks ), + }, + parents: { + ...omit( state.parents, visibleClientIds ), + ...mapBlockParents( action.blocks ), + }, + cache: { + ...omit( state.cache, visibleClientIds ), + ...mapValues( flattenBlocks( action.blocks ), () => ( {} ) ), + }, + }; + } + + return reducer( state, action ); +}; + +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state should become equivalent + * to the execution of a `REMOVE_BLOCKS` action containing all the child's of the root block followed by + * the execution of `INSERT_BLOCKS` with the new blocks. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => { + if ( action.type !== 'REPLACE_INNER_BLOCKS' ) { + return reducer( state, action ); + } + let stateAfterBlocksRemoval = state; + if ( state.order[ action.rootClientId ] ) { + stateAfterBlocksRemoval = reducer( stateAfterBlocksRemoval, { + type: 'REMOVE_BLOCKS', + clientIds: state.order[ action.rootClientId ], + } ); + } + let stateAfterInsert = stateAfterBlocksRemoval; + if ( action.blocks.length ) { + stateAfterInsert = reducer( stateAfterInsert, { + ...action, + type: 'INSERT_BLOCKS', + index: 0, + } ); + } + return stateAfterInsert; +}; + +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `SAVE_REUSABLE_BLOCK_SUCCESS` action. This action can't be handled by + * regular reducers and needs a higher-order reducer since it needs access to + * both `byClientId` and `attributes` simultaneously. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withSaveReusableBlock = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'SAVE_REUSABLE_BLOCK_SUCCESS' ) { + const { id, updatedId } = action; + + // If a temporary reusable block is saved, we swap the temporary id with the final one + if ( id === updatedId ) { + return state; + } + + state = { ...state }; + + state.attributes = mapValues( state.attributes, ( attributes, clientId ) => { + const { name } = state.byClientId[ clientId ]; + if ( name === 'core/block' && attributes.ref === id ) { + return { + ...attributes, + ref: updatedId, + }; + } + + return attributes; + } ); + } + + return reducer( state, action ); +}; + +/** + * Reducer returning the blocks state. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export const blocks = flow( + combineReducers, + withSaveReusableBlock, // needs to be before withBlockCache + withBlockCache, // needs to be before withInnerBlocksRemoveCascade + withInnerBlocksRemoveCascade, + withReplaceInnerBlocks, // needs to be after withInnerBlocksRemoveCascade + withBlockReset, + withPersistentBlockChange, + withIgnoredBlockChange, +)( { + byClientId( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlocksWithoutAttributes( action.blocks ); + + case 'RECEIVE_BLOCKS': + case 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'UPDATE_BLOCK': + // Ignore updates if block isn't known + if ( ! state[ action.clientId ] ) { + return state; + } + + // Do nothing if only attributes change. + const changes = omit( action.updates, 'attributes' ); + if ( isEmpty( changes ) ) { + return state; + } + + return { + ...state, + [ action.clientId ]: { + ...state[ action.clientId ], + ...changes, + }, + }; + + case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.replacedClientIds ), + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': + return omit( state, action.removedClientIds ); + } + + return state; + }, + + attributes( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlockAttributes( action.blocks ); + + case 'RECEIVE_BLOCKS': + case 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'UPDATE_BLOCK': + // Ignore updates if block isn't known or there are no attribute changes. + if ( ! state[ action.clientId ] || ! action.updates.attributes ) { + return state; + } + + return { + ...state, + [ action.clientId ]: { + ...state[ action.clientId ], + ...action.updates.attributes, + }, + }; + + case 'UPDATE_BLOCK_ATTRIBUTES': + // Ignore updates if block isn't known + if ( ! state[ action.clientId ] ) { + return state; + } + + // Consider as updates only changed values + const nextAttributes = reduce( action.attributes, ( result, value, key ) => { + if ( value !== result[ key ] ) { + result = getMutateSafeObject( state[ action.clientId ], result ); + result[ key ] = value; + } + + return result; + }, state[ action.clientId ] ); + + // Skip update if nothing has been changed. The reference will + // match the original block if `reduce` had no changed values. + if ( nextAttributes === state[ action.clientId ] ) { + return state; + } + + // Otherwise replace attributes in state + return { + ...state, + [ action.clientId ]: nextAttributes, + }; + + case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.replacedClientIds ), + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': + return omit( state, action.removedClientIds ); + } + + return state; + }, + + order( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return mapBlockOrder( action.blocks ); + + case 'RECEIVE_BLOCKS': + return { + ...state, + ...omit( mapBlockOrder( action.blocks ), '' ), + }; + + case 'INSERT_BLOCKS': { + const { rootClientId = '' } = action; + const subState = state[ rootClientId ] || []; + const mappedBlocks = mapBlockOrder( action.blocks, rootClientId ); + const { index = subState.length } = action; + + return { + ...state, + ...mappedBlocks, + [ rootClientId ]: insertAt( subState, mappedBlocks[ rootClientId ], index ), + }; + } + + case 'MOVE_BLOCK_TO_POSITION': { + const { fromRootClientId = '', toRootClientId = '', clientId } = action; + const { index = state[ toRootClientId ].length } = action; + + // Moving inside the same parent block + if ( fromRootClientId === toRootClientId ) { + const subState = state[ toRootClientId ]; + const fromIndex = subState.indexOf( clientId ); + return { + ...state, + [ toRootClientId ]: moveTo( state[ toRootClientId ], fromIndex, index ), + }; + } + + // Moving from a parent block to another + return { + ...state, + [ fromRootClientId ]: without( state[ fromRootClientId ], clientId ), + [ toRootClientId ]: insertAt( state[ toRootClientId ], clientId, index ), + }; + } + + case 'MOVE_BLOCKS_UP': { + const { clientIds, rootClientId = '' } = action; + const firstClientId = first( clientIds ); + const subState = state[ rootClientId ]; + + if ( ! subState.length || firstClientId === first( subState ) ) { + return state; + } + + const firstIndex = subState.indexOf( firstClientId ); + + return { + ...state, + [ rootClientId ]: moveTo( subState, firstIndex, firstIndex - 1, clientIds.length ), + }; + } + + case 'MOVE_BLOCKS_DOWN': { + const { clientIds, rootClientId = '' } = action; + const firstClientId = first( clientIds ); + const lastClientId = last( clientIds ); + const subState = state[ rootClientId ]; + + if ( ! subState.length || lastClientId === last( subState ) ) { + return state; + } + + const firstIndex = subState.indexOf( firstClientId ); + + return { + ...state, + [ rootClientId ]: moveTo( subState, firstIndex, firstIndex + 1, clientIds.length ), + }; + } + + case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { + const { clientIds } = action; + if ( ! action.blocks ) { + return state; + } + + const mappedBlocks = mapBlockOrder( action.blocks ); + + return flow( [ + ( nextState ) => omit( nextState, action.replacedClientIds ), + ( nextState ) => ( { + ...nextState, + ...omit( mappedBlocks, '' ), + } ), + ( nextState ) => mapValues( nextState, ( subState ) => ( + reduce( subState, ( result, clientId ) => { + if ( clientId === clientIds[ 0 ] ) { + return [ + ...result, + ...mappedBlocks[ '' ], + ]; + } + + if ( clientIds.indexOf( clientId ) === -1 ) { + result.push( clientId ); + } + + return result; + }, [] ) + ) ), + ] )( state ); + } + + case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': + return flow( [ + // Remove inner block ordering for removed blocks + ( nextState ) => omit( nextState, action.removedClientIds ), + + // Remove deleted blocks from other blocks' orderings + ( nextState ) => mapValues( nextState, ( subState ) => ( + without( subState, ...action.removedClientIds ) + ) ), + ] )( state ); + } + + return state; + }, + + // While technically redundant data as the inverse of `order`, it serves as + // an optimization for the selectors which derive the ancestry of a block. + parents( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return mapBlockParents( action.blocks ); + + case 'RECEIVE_BLOCKS': + return { + ...state, + ...mapBlockParents( action.blocks ), + }; + + case 'INSERT_BLOCKS': + return { + ...state, + ...mapBlockParents( action.blocks, action.rootClientId || '' ), + }; + + case 'MOVE_BLOCK_TO_POSITION': { + return { + ...state, + [ action.clientId ]: action.toRootClientId || '', + }; + } + + case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': + return { + ...omit( state, action.replacedClientIds ), + ...mapBlockParents( action.blocks, state[ action.clientIds[ 0 ] ] ), + }; + + case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': + return omit( state, action.removedClientIds ); + } + + return state; + }, +} ); + +/** + * Reducer returning typing state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isTyping( state = false, action ) { + switch ( action.type ) { + case 'START_TYPING': + return true; + + case 'STOP_TYPING': + return false; + } + + return state; +} + +/** + * Reducer returning whether the caret is within formatted text. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isCaretWithinFormattedText( state = false, action ) { + switch ( action.type ) { + case 'ENTER_FORMATTED_TEXT': + return true; + + case 'EXIT_FORMATTED_TEXT': + return false; + } + + return state; +} + +const BLOCK_SELECTION_EMPTY_OBJECT = {}; +const BLOCK_SELECTION_INITIAL_STATE = { + start: BLOCK_SELECTION_EMPTY_OBJECT, + end: BLOCK_SELECTION_EMPTY_OBJECT, + isMultiSelecting: false, + isEnabled: true, + initialPosition: null, +}; + +/** + * Reducer returning the block selection's state. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function blockSelection( state = BLOCK_SELECTION_INITIAL_STATE, action ) { + switch ( action.type ) { + case 'CLEAR_SELECTED_BLOCK': + if ( ! state.start || ! state.start.clientId ) { + return state; + } + + return { + ...state, + start: BLOCK_SELECTION_EMPTY_OBJECT, + end: BLOCK_SELECTION_EMPTY_OBJECT, + isMultiSelecting: false, + initialPosition: null, + }; + case 'START_MULTI_SELECT': + if ( state.isMultiSelecting ) { + return state; + } + + return { + ...state, + isMultiSelecting: true, + initialPosition: null, + }; + case 'STOP_MULTI_SELECT': + if ( ! state.isMultiSelecting ) { + return state; + } + + return { + ...state, + isMultiSelecting: false, + initialPosition: null, + }; + case 'MULTI_SELECT': + return { + ...state, + isMultiSelecting: state.isMultiSelecting, + start: { clientId: action.start }, + end: { clientId: action.end }, + }; + case 'SELECT_BLOCK': + if ( + action.clientId === state.start.clientId && + action.clientId === state.end.clientId + ) { + return state; + } + + return { + ...state, + initialPosition: action.initialPosition, + start: { clientId: action.clientId }, + end: { clientId: action.clientId }, + }; + case 'REPLACE_INNER_BLOCKS': // REPLACE_INNER_BLOCKS and INSERT_BLOCKS should follow the same logic. + case 'INSERT_BLOCKS': { + if ( action.updateSelection ) { + return { + ...state, + start: { clientId: action.blocks[ 0 ].clientId }, + end: { clientId: action.blocks[ 0 ].clientId }, + }; + } + + return state; + } + case 'REMOVE_BLOCKS': + if ( + ! action.clientIds || + ! action.clientIds.length || + action.clientIds.indexOf( state.start.clientId ) === -1 + ) { + return state; + } + + return { + ...state, + start: BLOCK_SELECTION_EMPTY_OBJECT, + end: BLOCK_SELECTION_EMPTY_OBJECT, + isMultiSelecting: false, + initialPosition: null, + }; + case 'REPLACE_BLOCKS': { + if ( action.clientIds.indexOf( state.start.clientId ) === -1 ) { + return state; + } + + const indexToSelect = action.indexToSelect || action.blocks.length - 1; + const blockToSelect = action.blocks[ indexToSelect ]; + + if ( ! blockToSelect ) { + return { + ...state, + start: BLOCK_SELECTION_EMPTY_OBJECT, + end: BLOCK_SELECTION_EMPTY_OBJECT, + isMultiSelecting: false, + initialPosition: null, + }; + } + + if ( + blockToSelect.clientId === state.start.clientId && + blockToSelect.clientId === state.end.clientId + ) { + return state; + } + + return { + ...state, + start: { clientId: blockToSelect.clientId }, + end: { clientId: blockToSelect.clientId }, + }; + } + case 'TOGGLE_SELECTION': + return { + ...state, + isEnabled: action.isSelectionEnabled, + }; + case 'SELECTION_CHANGE': + return { + ...state, + start: { + clientId: action.clientId, + attributeKey: action.attributeKey, + offset: action.startOffset, + }, + end: { + clientId: action.clientId, + attributeKey: action.attributeKey, + offset: action.endOffset, + }, + }; + } + + return state; +} + +export function blocksMode( state = {}, action ) { + if ( action.type === 'TOGGLE_BLOCK_MODE' ) { + const { clientId } = action; + return { + ...state, + [ clientId ]: state[ clientId ] && state[ clientId ] === 'html' ? 'visual' : 'html', + }; + } + + return state; +} + +/** + * Reducer returning the block insertion point visibility, either null if there + * is not an explicit insertion point assigned, or an object of its `index` and + * `rootClientId`. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function insertionPoint( state = null, action ) { + switch ( action.type ) { + case 'SHOW_INSERTION_POINT': + const { rootClientId, index } = action; + return { rootClientId, index }; + + case 'HIDE_INSERTION_POINT': + return null; + } + + return state; +} + +/** + * Reducer returning whether the post blocks match the defined template or not. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function template( state = { isValid: true }, action ) { + switch ( action.type ) { + case 'SET_TEMPLATE_VALIDITY': + return { + ...state, + isValid: action.isValid, + }; + } + + return state; +} + +/** + * Reducer returning the editor setting. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function settings( state = SETTINGS_DEFAULTS, action ) { + switch ( action.type ) { + case 'UPDATE_SETTINGS': + return { + ...state, + ...action.settings, + }; + } + + return state; +} + +/** + * Reducer returning the user preferences. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {string} Updated state. + */ +export function preferences( state = PREFERENCES_DEFAULTS, action ) { + switch ( action.type ) { + case 'INSERT_BLOCKS': + case 'REPLACE_BLOCKS': + return action.blocks.reduce( ( prevState, block ) => { + let id = block.name; + const insert = { name: block.name }; + if ( isReusableBlock( block ) ) { + insert.ref = block.attributes.ref; + id += '/' + block.attributes.ref; + } + + return { + ...prevState, + insertUsage: { + ...prevState.insertUsage, + [ id ]: { + time: action.time, + count: prevState.insertUsage[ id ] ? prevState.insertUsage[ id ].count + 1 : 1, + insert, + }, + }, + }; + }, state ); + } + + return state; +} + +/** + * Reducer returning an object where each key is a block client ID, its value + * representing the settings for its nested blocks. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export const blockListSettings = ( state = {}, action ) => { + switch ( action.type ) { + // Even if the replaced blocks have the same client ID, our logic + // should correct the state. + case 'REPLACE_BLOCKS' : + case 'REMOVE_BLOCKS': { + return omit( state, action.clientIds ); + } + case 'UPDATE_BLOCK_LIST_SETTINGS': { + const { clientId } = action; + if ( ! action.settings ) { + if ( state.hasOwnProperty( clientId ) ) { + return omit( state, clientId ); + } + + return state; + } + + if ( isEqual( state[ clientId ], action.settings ) ) { + return state; + } + + return { + ...state, + [ clientId ]: action.settings, + }; + } + } + return state; +}; + +/** + * Reducer returning whether the navigation mode is enabled or not. + * + * @param {string} state Current state. + * @param {Object} action Dispatched action. + * + * @return {string} Updated state. + */ +export function isNavigationMode( state = true, action ) { + if ( action.type === 'SET_NAVIGATION_MODE' ) { + return action.isNavigationMode; + } + + return state; +} + +/** + * Reducer return an updated state representing the most recent block attribute + * update. The state is structured as an object where the keys represent the + * client IDs of blocks, the values a subset of attributes from the most recent + * block update. The state is always reset to null if the last action is + * anything other than an attributes update. + * + * @param {Object<string,Object>} state Current state. + * @param {Object} action Action object. + * + * @return {[string,Object]} Updated state. + */ +export function lastBlockAttributesChange( state, action ) { + switch ( action.type ) { + case 'UPDATE_BLOCK': + if ( ! action.updates.attributes ) { + break; + } + + return { [ action.clientId ]: action.updates.attributes }; + + case 'UPDATE_BLOCK_ATTRIBUTES': + return { [ action.clientId ]: action.attributes }; + } + + return null; +} + +/** + * Reducer returning automatic change state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function didAutomaticChange( state, action ) { + return action.type === 'MARK_AUTOMATIC_CHANGE'; +} + +export default combineReducers( { + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + blocksMode, + blockListSettings, + insertionPoint, + template, + settings, + preferences, + lastBlockAttributesChange, + isNavigationMode, + didAutomaticChange, +} ); diff --git a/gb-src/packages/block-editor/src/store/selectors.js b/gb-src/packages/block-editor/src/store/selectors.js new file mode 100644 index 0000000000000..4ab5d663f551b --- /dev/null +++ b/gb-src/packages/block-editor/src/store/selectors.js @@ -0,0 +1,1402 @@ +/** + * External dependencies + */ +import { + castArray, + flatMap, + first, + get, + includes, + isArray, + isBoolean, + last, + map, + orderBy, + reduce, + some, + find, +} from 'lodash'; +import createSelector from 'rememo'; + +/** + * WordPress dependencies + */ +import { + getBlockType, + getBlockTypes, + hasBlockSupport, + parse, +} from '@wordpress/blocks'; +import { SVG, Rect, G, Path } from '@wordpress/components'; + +// Module constants + +/** + * @private + */ +export const INSERTER_UTILITY_HIGH = 3; + +/** + * @private + */ +export const INSERTER_UTILITY_MEDIUM = 2; + +/** + * @private + */ +export const INSERTER_UTILITY_LOW = 1; + +/** + * @private + */ +export const INSERTER_UTILITY_NONE = 0; + +const MILLISECONDS_PER_HOUR = 3600 * 1000; +const MILLISECONDS_PER_DAY = 24 * 3600 * 1000; +const MILLISECONDS_PER_WEEK = 7 * 24 * 3600 * 1000; +const templateIcon = <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Rect x="0" fill="none" width="24" height="24" /><G><Path d="M19 3H5c-1.105 0-2 .895-2 2v14c0 1.105.895 2 2 2h14c1.105 0 2-.895 2-2V5c0-1.105-.895-2-2-2zM6 6h5v5H6V6zm4.5 13C9.12 19 8 17.88 8 16.5S9.12 14 10.5 14s2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5zm3-6l3-5 3 5h-6z" /></G></SVG>; + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation, as in a connected or + * other pure component which performs `shouldComponentUpdate` check on props. + * This should be used as a last resort, since the normalized data should be + * maintained by the reducer result in state. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Returns a block's name given its client ID, or null if no block exists with + * the client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {string} Block name. + */ +export function getBlockName( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + return block ? block.name : null; +} + +/** + * Returns whether a block is valid or not. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Is Valid. + */ +export function isBlockValid( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + return !! block && block.isValid; +} + +/** + * Returns a block's attributes given its client ID, or null if no block exists with + * the client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object?} Block attributes. + */ +export function getBlockAttributes( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + return state.blocks.attributes[ clientId ]; +} + +/** + * Returns a block given its client ID. This is a parsed copy of the block, + * containing its `blockName`, `clientId`, and current `attributes` state. This + * is not the block's registration settings, which must be retrieved from the + * blocks module registration store. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object} Parsed block object. + */ +export const getBlock = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + return { + ...block, + attributes: getBlockAttributes( state, clientId ), + innerBlocks: getBlocks( state, clientId ), + }; + }, + ( state, clientId ) => [ + // Normally, we'd have both `getBlockAttributes` dependancies and + // `getBlocks` (children) dependancies here but for performance reasons + // we use a denormalized cache key computed in the reducer that takes both + // the attributes and inner blocks into account. The value of the cache key + // is being changed whenever one of these dependencies is out of date. + state.blocks.cache[ clientId ], + ] +); + +export const __unstableGetBlockWithoutInnerBlocks = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + return { + ...block, + attributes: getBlockAttributes( state, clientId ), + }; + }, + ( state, clientId ) => [ + state.blocks.byClientId[ clientId ], + state.blocks.attributes[ clientId ], + ] +); + +/** + * Returns all block objects for the current post being edited as an array in + * the order they appear in the post. + * + * Note: It's important to memoize this selector to avoid return a new instance + * on each call + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Object[]} Post blocks. + */ +export const getBlocks = createSelector( + ( state, rootClientId ) => { + return map( + getBlockOrder( state, rootClientId ), + ( clientId ) => getBlock( state, clientId ) + ); + }, + ( state ) => [ + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + ] +); + +/** + * Returns an array containing the clientIds of all descendants + * of the blocks given. + * + * @param {Object} state Global application state. + * @param {Array} clientIds Array of blocks to inspect. + * + * @return {Array} ids of descendants. + */ +export const getClientIdsOfDescendants = ( state, clientIds ) => flatMap( clientIds, ( clientId ) => { + const descendants = getBlockOrder( state, clientId ); + return [ ...descendants, ...getClientIdsOfDescendants( state, descendants ) ]; +} ); + +/** + * Returns an array containing the clientIds of the top-level blocks + * and their descendants of any depth (for nested blocks). + * + * @param {Object} state Global application state. + * + * @return {Array} ids of top-level and descendant blocks. + */ +export const getClientIdsWithDescendants = createSelector( + ( state ) => { + const topLevelIds = getBlockOrder( state ); + return [ ...topLevelIds, ...getClientIdsOfDescendants( state, topLevelIds ) ]; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * Returns the total number of blocks, or the total number of blocks with a specific name in a post. + * The number returned includes nested blocks. + * + * @param {Object} state Global application state. + * @param {?string} blockName Optional block name, if specified only blocks of that type will be counted. + * + * @return {number} Number of blocks in the post, or number of blocks with name equal to blockName. + */ +export const getGlobalBlockCount = createSelector( + ( state, blockName ) => { + const clientIds = getClientIdsWithDescendants( state ); + if ( ! blockName ) { + return clientIds.length; + } + return reduce( clientIds, ( count, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + return block.name === blockName ? count + 1 : count; + }, 0 ); + }, + ( state ) => [ + state.blocks.order, + state.blocks.byClientId, + ] +); + +/** + * Given an array of block client IDs, returns the corresponding array of block + * objects. + * + * @param {Object} state Editor state. + * @param {string[]} clientIds Client IDs for which blocks are to be returned. + * + * @return {WPBlock[]} Block objects. + */ +export const getBlocksByClientId = createSelector( + ( state, clientIds ) => map( + castArray( clientIds ), + ( clientId ) => getBlock( state, clientId ) + ), + ( state ) => [ + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + ] +); + +/** + * Returns the number of blocks currently present in the post. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {number} Number of blocks in the post. + */ +export function getBlockCount( state, rootClientId ) { + return getBlockOrder( state, rootClientId ).length; +} + +/** + * @typedef {WPBlockSelection} A block selection object. + * + * @property {string} clientId The selected block client ID. + * @property {string} attributeKey The selected block attribute key. + * @property {number} offset The selected block attribute offset. + */ + +/** + * Returns the current selection start block client ID, attribute key and text + * offset. + * + * @param {Object} state Block editor state. + * + * @return {WPBlockSelection} Selection start information. + */ +export function getSelectionStart( state ) { + return state.blockSelection.start; +} + +/** + * Returns the current selection end block client ID, attribute key and text + * offset. + * + * @param {Object} state Block editor state. + * + * @return {WPBlockSelection} Selection end information. + */ +export function getSelectionEnd( state ) { + return state.blockSelection.end; +} + +/** + * Returns the current block selection start. This value may be null, and it + * may represent either a singular block selection or multi-selection start. + * A selection is singular if its start and end match. + * + * @param {Object} state Global application state. + * + * @return {?string} Client ID of block selection start. + */ +export function getBlockSelectionStart( state ) { + return state.blockSelection.start.clientId; +} + +/** + * Returns the current block selection end. This value may be null, and it + * may represent either a singular block selection or multi-selection end. + * A selection is singular if its start and end match. + * + * @param {Object} state Global application state. + * + * @return {?string} Client ID of block selection end. + */ +export function getBlockSelectionEnd( state ) { + return state.blockSelection.end.clientId; +} + +/** + * Returns the number of blocks currently selected in the post. + * + * @param {Object} state Global application state. + * + * @return {number} Number of blocks selected in the post. + */ +export function getSelectedBlockCount( state ) { + const multiSelectedBlockCount = getMultiSelectedBlockClientIds( state ).length; + + if ( multiSelectedBlockCount ) { + return multiSelectedBlockCount; + } + + return state.blockSelection.start.clientId ? 1 : 0; +} + +/** + * Returns true if there is a single selected block, or false otherwise. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether a single block is selected. + */ +export function hasSelectedBlock( state ) { + const { start, end } = state.blockSelection; + return !! start.clientId && start.clientId === end.clientId; +} + +/** + * Returns the currently selected block client ID, or null if there is no + * selected block. + * + * @param {Object} state Editor state. + * + * @return {?string} Selected block client ID. + */ +export function getSelectedBlockClientId( state ) { + const { start, end } = state.blockSelection; + // We need to check the block exists because the current blockSelection + // reducer doesn't take into account when blocks are reset via undo. To be + // removed when that's fixed. + return start.clientId && start.clientId === end.clientId && !! state.blocks.byClientId[ start.clientId ] ? start.clientId : null; +} + +/** + * Returns the currently selected block, or null if there is no selected block. + * + * @param {Object} state Global application state. + * + * @return {?Object} Selected block. + */ +export function getSelectedBlock( state ) { + const clientId = getSelectedBlockClientId( state ); + return clientId ? getBlock( state, clientId ) : null; +} + +/** + * Given a block client ID, returns the root block from which the block is + * nested, an empty string for top-level blocks, or null if the block does not + * exist. + * + * @param {Object} state Editor state. + * @param {string} clientId Block from which to find root client ID. + * + * @return {?string} Root client ID, if exists + */ +export function getBlockRootClientId( state, clientId ) { + return state.blocks.parents[ clientId ] !== undefined ? + state.blocks.parents[ clientId ] : + null; +} + +/** + * Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. + * + * @param {Object} state Editor state. + * @param {string} clientId Block from which to find root client ID. + * + * @return {string} Root client ID + */ +export function getBlockHierarchyRootClientId( state, clientId ) { + let current = clientId; + let parent; + do { + parent = current; + current = state.blocks.parents[ current ]; + } while ( current ); + return parent; +} + +/** + * Returns the client ID of the block adjacent one at the given reference + * startClientId and modifier directionality. Defaults start startClientId to + * the selected block, and direction as next block. Returns null if there is no + * adjacent block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * @param {?number} modifier Directionality multiplier (1 next, -1 + * previous). + * + * @return {?string} Return the client ID of the block, or null if none exists. + */ +export function getAdjacentBlockClientId( state, startClientId, modifier = 1 ) { + // Default to selected block. + if ( startClientId === undefined ) { + startClientId = getSelectedBlockClientId( state ); + } + + // Try multi-selection starting at extent based on modifier. + if ( startClientId === undefined ) { + if ( modifier < 0 ) { + startClientId = getFirstMultiSelectedBlockClientId( state ); + } else { + startClientId = getLastMultiSelectedBlockClientId( state ); + } + } + + // Validate working start client ID. + if ( ! startClientId ) { + return null; + } + + // Retrieve start block root client ID, being careful to allow the falsey + // empty string top-level root by explicitly testing against null. + const rootClientId = getBlockRootClientId( state, startClientId ); + if ( rootClientId === null ) { + return null; + } + + const { order } = state.blocks; + const orderSet = order[ rootClientId ]; + const index = orderSet.indexOf( startClientId ); + const nextIndex = ( index + ( 1 * modifier ) ); + + // Block was first in set and we're attempting to get previous. + if ( nextIndex < 0 ) { + return null; + } + + // Block was last in set and we're attempting to get next. + if ( nextIndex === orderSet.length ) { + return null; + } + + // Assume incremented index is within the set. + return orderSet[ nextIndex ]; +} + +/** + * Returns the previous block's client ID from the given reference start ID. + * Defaults start to the selected block. Returns null if there is no previous + * block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * + * @return {?string} Adjacent block's client ID, or null if none exists. + */ +export function getPreviousBlockClientId( state, startClientId ) { + return getAdjacentBlockClientId( state, startClientId, -1 ); +} + +/** + * Returns the next block's client ID from the given reference start ID. + * Defaults start to the selected block. Returns null if there is no next + * block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * + * @return {?string} Adjacent block's client ID, or null if none exists. + */ +export function getNextBlockClientId( state, startClientId ) { + return getAdjacentBlockClientId( state, startClientId, 1 ); +} + +/** + * Returns the initial caret position for the selected block. + * This position is to used to position the caret properly when the selected block changes. + * + * @param {Object} state Global application state. + * + * @return {?Object} Selected block. + */ +export function getSelectedBlocksInitialCaretPosition( state ) { + const { start, end } = state.blockSelection; + + if ( start.clientId !== end.clientId || ! start.clientId ) { + return null; + } + + return state.blockSelection.initialPosition; +} + +/** + * Returns the current selection set of block client IDs (multiselection or single selection). + * + * @param {Object} state Editor state. + * + * @return {Array} Multi-selected block client IDs. + */ +export const getSelectedBlockClientIds = createSelector( + ( state ) => { + const { start, end } = state.blockSelection; + + if ( start.clientId === undefined || end.clientId === undefined ) { + return EMPTY_ARRAY; + } + + if ( start.clientId === end.clientId ) { + return [ start.clientId ]; + } + + // Retrieve root client ID to aid in retrieving relevant nested block + // order, being careful to allow the falsey empty string top-level root + // by explicitly testing against null. + const rootClientId = getBlockRootClientId( state, start.clientId ); + if ( rootClientId === null ) { + return EMPTY_ARRAY; + } + + const blockOrder = getBlockOrder( state, rootClientId ); + const startIndex = blockOrder.indexOf( start.clientId ); + const endIndex = blockOrder.indexOf( end.clientId ); + + if ( startIndex > endIndex ) { + return blockOrder.slice( endIndex, startIndex + 1 ); + } + + return blockOrder.slice( startIndex, endIndex + 1 ); + }, + ( state ) => [ + state.blocks.order, + state.blockSelection.start.clientId, + state.blockSelection.end.clientId, + ], +); + +/** + * Returns the current multi-selection set of block client IDs, or an empty + * array if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {Array} Multi-selected block client IDs. + */ +export function getMultiSelectedBlockClientIds( state ) { + const { start, end } = state.blockSelection; + + if ( start.clientId === end.clientId ) { + return EMPTY_ARRAY; + } + + return getSelectedBlockClientIds( state ); +} + +/** + * Returns the current multi-selection set of blocks, or an empty array if + * there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {Array} Multi-selected block objects. + */ +export const getMultiSelectedBlocks = createSelector( + ( state ) => { + const multiSelectedBlockClientIds = getMultiSelectedBlockClientIds( state ); + if ( ! multiSelectedBlockClientIds.length ) { + return EMPTY_ARRAY; + } + + return multiSelectedBlockClientIds.map( ( clientId ) => getBlock( state, clientId ) ); + }, + ( state ) => [ + ...getSelectedBlockClientIds.getDependants( state ), + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + ] +); + +/** + * Returns the client ID of the first block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {?string} First block client ID in the multi-selection set. + */ +export function getFirstMultiSelectedBlockClientId( state ) { + return first( getMultiSelectedBlockClientIds( state ) ) || null; +} + +/** + * Returns the client ID of the last block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {?string} Last block client ID in the multi-selection set. + */ +export function getLastMultiSelectedBlockClientId( state ) { + return last( getMultiSelectedBlockClientIds( state ) ) || null; +} + +/** + * Returns true if a multi-selection exists, and the block corresponding to the + * specified client ID is the first block of the multi-selection set, or false + * otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is first in multi-selection. + */ +export function isFirstMultiSelectedBlock( state, clientId ) { + return getFirstMultiSelectedBlockClientId( state ) === clientId; +} + +/** + * Returns true if the client ID occurs within the block multi-selection, or + * false otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is in multi-selection set. + */ +export function isBlockMultiSelected( state, clientId ) { + return getMultiSelectedBlockClientIds( state ).indexOf( clientId ) !== -1; +} + +/** + * Returns true if an ancestor of the block is multi-selected, or false + * otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether an ancestor of the block is in multi-selection + * set. + */ +export const isAncestorMultiSelected = createSelector( + ( state, clientId ) => { + let ancestorClientId = clientId; + let isMultiSelected = false; + while ( ancestorClientId && ! isMultiSelected ) { + ancestorClientId = getBlockRootClientId( state, ancestorClientId ); + isMultiSelected = isBlockMultiSelected( state, ancestorClientId ); + } + return isMultiSelected; + }, + ( state ) => [ + state.blocks.order, + state.blockSelection.start.clientId, + state.blockSelection.end.clientId, + ], +); +/** + * Returns the client ID of the block which begins the multi-selection set, or + * null if there is no multi-selection. + * + * This is not necessarily the first client ID in the selection. + * + * @see getFirstMultiSelectedBlockClientId + * + * @param {Object} state Editor state. + * + * @return {?string} Client ID of block beginning multi-selection. + */ +export function getMultiSelectedBlocksStartClientId( state ) { + const { start, end } = state.blockSelection; + if ( start.clientId === end.clientId ) { + return null; + } + return start.clientId || null; +} + +/** + * Returns the client ID of the block which ends the multi-selection set, or + * null if there is no multi-selection. + * + * This is not necessarily the last client ID in the selection. + * + * @see getLastMultiSelectedBlockClientId + * + * @param {Object} state Editor state. + * + * @return {?string} Client ID of block ending multi-selection. + */ +export function getMultiSelectedBlocksEndClientId( state ) { + const { start, end } = state.blockSelection; + if ( start.clientId === end.clientId ) { + return null; + } + return end.clientId || null; +} + +/** + * Returns an array containing all block client IDs in the editor in the order + * they appear. Optionally accepts a root client ID of the block list for which + * the order should be returned, defaulting to the top-level block order. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Array} Ordered client IDs of editor blocks. + */ +export function getBlockOrder( state, rootClientId ) { + return state.blocks.order[ rootClientId || '' ] || EMPTY_ARRAY; +} + +/** + * Returns the index at which the block corresponding to the specified client + * ID occurs within the block order, or `-1` if the block does not exist. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {number} Index at which block exists in order. + */ +export function getBlockIndex( state, clientId, rootClientId ) { + return getBlockOrder( state, rootClientId ).indexOf( clientId ); +} + +/** + * Returns true if the block corresponding to the specified client ID is + * currently selected and no multi-selection exists, or false otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is selected and multi-selection exists. + */ +export function isBlockSelected( state, clientId ) { + const { start, end } = state.blockSelection; + + if ( start.clientId !== end.clientId ) { + return false; + } + + return start.clientId === clientId; +} + +/** + * Returns true if one of the block's inner blocks is selected. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * @param {boolean} deep Perform a deep check. + * + * @return {boolean} Whether the block as an inner block selected + */ +export function hasSelectedInnerBlock( state, clientId, deep = false ) { + return some( + getBlockOrder( state, clientId ), + ( innerClientId ) => ( + isBlockSelected( state, innerClientId ) || + isBlockMultiSelected( state, innerClientId ) || + ( deep && hasSelectedInnerBlock( state, innerClientId, deep ) ) + ) + ); +} + +/** + * Returns true if the block corresponding to the specified client ID is + * currently selected but isn't the last of the selected blocks. Here "last" + * refers to the block sequence in the document, _not_ the sequence of + * multi-selection, which is why `state.blockSelection.end` isn't used. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is selected and not the last in the + * selection. + */ +export function isBlockWithinSelection( state, clientId ) { + if ( ! clientId ) { + return false; + } + + const clientIds = getMultiSelectedBlockClientIds( state ); + const index = clientIds.indexOf( clientId ); + return index > -1 && index < clientIds.length - 1; +} + +/** + * Returns true if a multi-selection has been made, or false otherwise. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether multi-selection has been made. + */ +export function hasMultiSelection( state ) { + const { start, end } = state.blockSelection; + return start.clientId !== end.clientId; +} + +/** + * Whether in the process of multi-selecting or not. This flag is only true + * while the multi-selection is being selected (by mouse move), and is false + * once the multi-selection has been settled. + * + * @see hasMultiSelection + * + * @param {Object} state Global application state. + * + * @return {boolean} True if multi-selecting, false if not. + */ +export function isMultiSelecting( state ) { + return state.blockSelection.isMultiSelecting; +} + +/** + * Selector that returns if multi-selection is enabled or not. + * + * @param {Object} state Global application state. + * + * @return {boolean} True if it should be possible to multi-select blocks, false if multi-selection is disabled. + */ +export function isSelectionEnabled( state ) { + return state.blockSelection.isEnabled; +} + +/** + * Returns the block's editing mode, defaulting to "visual" if not explicitly + * assigned. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object} Block editing mode. + */ +export function getBlockMode( state, clientId ) { + return state.blocksMode[ clientId ] || 'visual'; +} + +/** + * Returns true if the user is typing, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether user is typing. + */ +export function isTyping( state ) { + return state.isTyping; +} + +/** + * Returns true if the caret is within formatted text, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the caret is within formatted text. + */ +export function isCaretWithinFormattedText( state ) { + return state.isCaretWithinFormattedText; +} + +/** + * Returns the insertion point, the index at which the new inserted block would + * be placed. Defaults to the last index. + * + * @param {Object} state Editor state. + * + * @return {Object} Insertion point object with `rootClientId`, `index`. + */ +export function getBlockInsertionPoint( state ) { + let rootClientId, index; + + const { insertionPoint, blockSelection } = state; + if ( insertionPoint !== null ) { + return insertionPoint; + } + + const { end } = blockSelection; + if ( end.clientId ) { + rootClientId = getBlockRootClientId( state, end.clientId ) || undefined; + index = getBlockIndex( state, end.clientId, rootClientId ) + 1; + } else { + index = getBlockOrder( state ).length; + } + + return { rootClientId, index }; +} + +/** + * Returns true if we should show the block insertion point. + * + * @param {Object} state Global application state. + * + * @return {?boolean} Whether the insertion point is visible or not. + */ +export function isBlockInsertionPointVisible( state ) { + return state.insertionPoint !== null; +} + +/** + * Returns whether the blocks matches the template or not. + * + * @param {boolean} state + * @return {?boolean} Whether the template is valid or not. + */ +export function isValidTemplate( state ) { + return state.template.isValid; +} + +/** + * Returns the defined block template + * + * @param {boolean} state + * @return {?Array} Block Template + */ +export function getTemplate( state ) { + return state.settings.template; +} + +/** + * Returns the defined block template lock. Optionally accepts a root block + * client ID as context, otherwise defaulting to the global context. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional block root client ID. + * + * @return {?string} Block Template Lock + */ +export function getTemplateLock( state, rootClientId ) { + if ( ! rootClientId ) { + return state.settings.templateLock; + } + + const blockListSettings = getBlockListSettings( state, rootClientId ); + if ( ! blockListSettings ) { + return null; + } + + return blockListSettings.templateLock; +} + +/** + * Determines if the given block type is allowed to be inserted into the block list. + * This function is not exported and not memoized because using a memoized selector + * inside another memoized selector is just a waste of time. + * + * @param {Object} state Editor state. + * @param {string} blockName The name of the block type, e.g.' core/paragraph'. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +const canInsertBlockTypeUnmemoized = ( state, blockName, rootClientId = null ) => { + const checkAllowList = ( list, item, defaultResult = null ) => { + if ( isBoolean( list ) ) { + return list; + } + if ( isArray( list ) ) { + return includes( list, item ); + } + return defaultResult; + }; + + const blockType = getBlockType( blockName ); + if ( ! blockType ) { + return false; + } + + const { allowedBlockTypes } = getSettings( state ); + + const isBlockAllowedInEditor = checkAllowList( allowedBlockTypes, blockName, true ); + if ( ! isBlockAllowedInEditor ) { + return false; + } + + const isLocked = !! getTemplateLock( state, rootClientId ); + if ( isLocked ) { + return false; + } + + const parentBlockListSettings = getBlockListSettings( state, rootClientId ); + const parentAllowedBlocks = get( parentBlockListSettings, [ 'allowedBlocks' ] ); + const hasParentAllowedBlock = checkAllowList( parentAllowedBlocks, blockName ); + + const blockAllowedParentBlocks = blockType.parent; + const parentName = getBlockName( state, rootClientId ); + const hasBlockAllowedParent = checkAllowList( blockAllowedParentBlocks, parentName ); + + if ( hasParentAllowedBlock !== null && hasBlockAllowedParent !== null ) { + return hasParentAllowedBlock || hasBlockAllowedParent; + } else if ( hasParentAllowedBlock !== null ) { + return hasParentAllowedBlock; + } else if ( hasBlockAllowedParent !== null ) { + return hasBlockAllowedParent; + } + + return true; +}; + +/** + * Determines if the given block type is allowed to be inserted into the block list. + * + * @param {Object} state Editor state. + * @param {string} blockName The name of the block type, e.g.' core/paragraph'. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +export const canInsertBlockType = createSelector( + canInsertBlockTypeUnmemoized, + ( state, blockName, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId[ rootClientId ], + state.settings.allowedBlockTypes, + state.settings.templateLock, + ], +); + +/** + * Returns information about how recently and frequently a block has been inserted. + * + * @param {Object} state Global application state. + * @param {string} id A string which identifies the insert, e.g. 'core/block/12' + * + * @return {?{ time: number, count: number }} An object containing `time` which is when the last + * insert occurred as a UNIX epoch, and `count` which is + * the number of inserts that have occurred. + */ +function getInsertUsage( state, id ) { + return get( state.preferences.insertUsage, [ id ], null ); +} + +/** + * Returns whether we can show a block type in the inserter + * + * @param {Object} state Global State + * @param {Object} blockType BlockType + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be shown in the inserter. + */ +const canIncludeBlockTypeInInserter = ( state, blockType, rootClientId ) => { + if ( ! hasBlockSupport( blockType, 'inserter', true ) ) { + return false; + } + + return canInsertBlockTypeUnmemoized( state, blockType.name, rootClientId ); +}; + +/** + * Determines the items that appear in the inserter. Includes both static + * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). + * + * Each item object contains what's necessary to display a button in the + * inserter and handle its selection. + * + * The 'utility' property indicates how useful we think an item will be to the + * user. There are 4 levels of utility: + * + * 1. Blocks that are contextually useful (utility = 3) + * 2. Blocks that have been previously inserted (utility = 2) + * 3. Blocks that are in the common category (utility = 1) + * 4. All other blocks (utility = 0) + * + * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) + * that combines block usage frequenty and recency. + * + * Items are returned ordered descendingly by their 'utility' and 'frecency'. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Editor.InserterItem[]} Items that appear in inserter. + * + * @typedef {Object} Editor.InserterItem + * @property {string} id Unique identifier for the item. + * @property {string} name The type of block to create. + * @property {Object} initialAttributes Attributes to pass to the newly created block. + * @property {string} title Title of the item, as it appears in the inserter. + * @property {string} icon Dashicon for the item, as it appears in the inserter. + * @property {string} category Block category that the item is associated with. + * @property {string[]} keywords Keywords that can be searched to find this item. + * @property {boolean} isDisabled Whether or not the user should be prevented from inserting + * this item. + * @property {number} utility How useful we think this item is, between 0 and 3. + * @property {number} frecency Hueristic that combines frequency and recency. + */ +export const getInserterItems = createSelector( + ( state, rootClientId = null ) => { + const calculateUtility = ( category, count, isContextual ) => { + if ( isContextual ) { + return INSERTER_UTILITY_HIGH; + } else if ( count > 0 ) { + return INSERTER_UTILITY_MEDIUM; + } else if ( category === 'common' ) { + return INSERTER_UTILITY_LOW; + } + return INSERTER_UTILITY_NONE; + }; + + const calculateFrecency = ( time, count ) => { + if ( ! time ) { + return count; + } + + // The selector is cached, which means Date.now() is the last time that the + // relevant state changed. This suits our needs. + const duration = Date.now() - time; + + switch ( true ) { + case duration < MILLISECONDS_PER_HOUR: + return count * 4; + case duration < MILLISECONDS_PER_DAY: + return count * 2; + case duration < MILLISECONDS_PER_WEEK: + return count / 2; + default: + return count / 4; + } + }; + + const buildBlockTypeInserterItem = ( blockType ) => { + const id = blockType.name; + + let isDisabled = false; + if ( ! hasBlockSupport( blockType.name, 'multiple', true ) ) { + isDisabled = some( getBlocksByClientId( state, getClientIdsWithDescendants( state ) ), { name: blockType.name } ); + } + + const isContextual = isArray( blockType.parent ); + const { time, count = 0 } = getInsertUsage( state, id ) || {}; + + return { + id, + name: blockType.name, + initialAttributes: {}, + title: blockType.title, + icon: blockType.icon, + category: blockType.category, + keywords: blockType.keywords, + isDisabled, + utility: calculateUtility( blockType.category, count, isContextual ), + frecency: calculateFrecency( time, count ), + }; + }; + + const buildReusableBlockInserterItem = ( reusableBlock ) => { + const id = `core/block/${ reusableBlock.id }`; + + const referencedBlocks = __experimentalGetParsedReusableBlock( state, reusableBlock.id ); + let referencedBlockType; + if ( referencedBlocks.length === 1 ) { + referencedBlockType = getBlockType( referencedBlocks[ 0 ].name ); + } + + const { time, count = 0 } = getInsertUsage( state, id ) || {}; + const utility = calculateUtility( 'reusable', count, false ); + const frecency = calculateFrecency( time, count ); + + return { + id, + name: 'core/block', + initialAttributes: { ref: reusableBlock.id }, + title: reusableBlock.title, + icon: referencedBlockType ? referencedBlockType.icon : templateIcon, + category: 'reusable', + keywords: [], + isDisabled: false, + utility, + frecency, + }; + }; + + const blockTypeInserterItems = getBlockTypes() + .filter( ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) ) + .map( buildBlockTypeInserterItem ); + + const reusableBlockInserterItems = canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) ? + getReusableBlocks( state ).map( buildReusableBlockInserterItem ) : + []; + + return orderBy( + [ ...blockTypeInserterItems, ...reusableBlockInserterItems ], + [ 'utility', 'frecency' ], + [ 'desc', 'desc' ] + ); + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId, + state.blocks.order, + state.preferences.insertUsage, + state.settings.allowedBlockTypes, + state.settings.templateLock, + getReusableBlocks( state ), + getBlockTypes(), + ], +); + +/** + * Determines whether there are items to show in the inserter. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Items that appear in inserter. + */ +export const hasInserterItems = createSelector( + ( state, rootClientId = null ) => { + const hasBlockType = some( + getBlockTypes(), + ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) + ); + if ( hasBlockType ) { + return true; + } + const hasReusableBlock = ( + canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) && + getReusableBlocks( state ).length > 0 + ); + + return hasReusableBlock; + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId, + state.settings.allowedBlockTypes, + state.settings.templateLock, + getReusableBlocks( state ), + getBlockTypes(), + ], +); + +/** + * Returns the Block List settings of a block, if any exist. + * + * @param {Object} state Editor state. + * @param {?string} clientId Block client ID. + * + * @return {?Object} Block settings of the block if set. + */ +export function getBlockListSettings( state, clientId ) { + return state.blockListSettings[ clientId ]; +} + +/** + * Returns the editor settings. + * + * @param {Object} state Editor state. + * + * @return {Object} The editor settings object. + */ +export function getSettings( state ) { + return state.settings; +} + +/** + * Returns true if the most recent block change is be considered persistent, or + * false otherwise. A persistent change is one committed by BlockEditorProvider + * via its `onChange` callback, in addition to `onInput`. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change was persistent. + */ +export function isLastBlockChangePersistent( state ) { + return state.blocks.isPersistentChange; +} + +/** + * Returns the parsed block saved as shared block with the given ID. + * + * @param {Object} state Global application state. + * @param {number|string} ref The shared block's ID. + * + * @return {Object} The parsed block. + */ +export const __experimentalGetParsedReusableBlock = createSelector( + ( state, ref ) => { + const reusableBlock = find( + getReusableBlocks( state ), + ( block ) => block.id === ref + ); + if ( ! reusableBlock ) { + return null; + } + + return parse( reusableBlock.content ); + }, + ( state ) => [ + getReusableBlocks( state ), + ], +); + +/** + * Returns true if the most recent block change is be considered ignored, or + * false otherwise. An ignored change is one not to be committed by + * BlockEditorProvider, neither via `onChange` nor `onInput`. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change was ignored. + */ +export function __unstableIsLastBlockChangeIgnored( state ) { + // TODO: Removal Plan: Changes incurred by RECEIVE_BLOCKS should not be + // ignored if in-fact they result in a change in blocks state. The current + // need to ignore changes not a result of user interaction should be + // accounted for in the refactoring of reusable blocks as occurring within + // their own separate block editor / state (#7119). + return state.blocks.isIgnoredChange; +} + +/** + * Returns the block attributes changed as a result of the last dispatched + * action. + * + * @param {Object} state Block editor state. + * + * @return {Object<string,Object>} Subsets of block attributes changed, keyed + * by block client ID. + */ +export function __experimentalGetLastBlockAttributeChanges( state ) { + return state.lastBlockAttributesChange; +} + +/** + * Returns the available reusable blocks + * + * @param {Object} state Global application state. + * + * @return {Array} Reusable blocks + */ +function getReusableBlocks( state ) { + return get( state, [ 'settings', '__experimentalReusableBlocks' ], EMPTY_ARRAY ); +} + +/** + * Returns whether the navigation mode is enabled. + * + * @param {Object} state Editor state. + * + * @return {boolean} Is navigation mode enabled. + */ +export function isNavigationMode( state ) { + return state.isNavigationMode; +} + +/** + * Returns true if the last change was an automatic change, false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the last change was automatic. + */ +export function didAutomaticChange( state ) { + return state.didAutomaticChange; +} diff --git a/gb-src/packages/block-editor/src/store/test/actions.js b/gb-src/packages/block-editor/src/store/test/actions.js new file mode 100644 index 0000000000000..a2f6d798e9f7d --- /dev/null +++ b/gb-src/packages/block-editor/src/store/test/actions.js @@ -0,0 +1,960 @@ +/** + * Internal dependencies + */ +import { + clearSelectedBlock, + enterFormattedText, + exitFormattedText, + hideInsertionPoint, + insertBlock, + insertBlocks, + mergeBlocks, + moveBlockToPosition, + multiSelect, + removeBlock, + removeBlocks, + replaceBlock, + replaceBlocks, + replaceInnerBlocks, + resetBlocks, + selectBlock, + selectPreviousBlock, + showInsertionPoint, + startMultiSelect, + startTyping, + stopMultiSelect, + stopTyping, + toggleBlockMode, + toggleSelection, + updateBlock, + updateBlockAttributes, + updateBlockListSettings, +} from '../actions'; +import { select } from '../controls'; + +describe( 'actions', () => { + describe( 'resetBlocks', () => { + it( 'should return the RESET_BLOCKS actions', () => { + const blocks = []; + const result = resetBlocks( blocks ); + expect( result ).toEqual( { + type: 'RESET_BLOCKS', + blocks, + } ); + } ); + } ); + + describe( 'updateBlockAttributes', () => { + it( 'should return the UPDATE_BLOCK_ATTRIBUTES action', () => { + const clientId = 'myclientid'; + const attributes = {}; + const result = updateBlockAttributes( clientId, attributes ); + expect( result ).toEqual( { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId, + attributes, + } ); + } ); + } ); + + describe( 'updateBlock', () => { + it( 'should return the UPDATE_BLOCK action', () => { + const clientId = 'myclientid'; + const updates = {}; + const result = updateBlock( clientId, updates ); + expect( result ).toEqual( { + type: 'UPDATE_BLOCK', + clientId, + updates, + } ); + } ); + } ); + + describe( 'selectBlock', () => { + it( 'should return the SELECT_BLOCK action', () => { + const clientId = 'myclientid'; + const result = selectBlock( clientId, -1 ); + expect( result ).toEqual( { + type: 'SELECT_BLOCK', + initialPosition: -1, + clientId, + } ); + } ); + } ); + + describe( 'startMultiSelect', () => { + it( 'should return the START_MULTI_SELECT', () => { + expect( startMultiSelect() ).toEqual( { + type: 'START_MULTI_SELECT', + } ); + } ); + } ); + + describe( 'stopMultiSelect', () => { + it( 'should return the Stop_MULTI_SELECT', () => { + expect( stopMultiSelect() ).toEqual( { + type: 'STOP_MULTI_SELECT', + } ); + } ); + } ); + describe( 'multiSelect', () => { + it( 'should return MULTI_SELECT action', () => { + const start = 'start'; + const end = 'end'; + expect( multiSelect( start, end ) ).toEqual( { + type: 'MULTI_SELECT', + start, + end, + } ); + } ); + } ); + + describe( 'clearSelectedBlock', () => { + it( 'should return CLEAR_SELECTED_BLOCK action', () => { + expect( clearSelectedBlock() ).toEqual( { + type: 'CLEAR_SELECTED_BLOCK', + } ); + } ); + } ); + + describe( 'replaceBlock', () => { + it( 'should yield the REPLACE_BLOCKS action if the new block can be inserted in the destination root block', () => { + const block = { + clientId: 'ribs', + name: 'core/test-block', + }; + + const replaceBlockGenerator = replaceBlock( 'chicken', block ); + + // Skip getSettings select. + replaceBlockGenerator.next(); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-block', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ block ], + time: expect.any( Number ), + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); + } ); + } ); + + describe( 'replaceBlocks', () => { + it( 'should not yield the REPLACE_BLOCKS action if the replacement is not possible', () => { + const blocks = [ { + clientId: 'ribs', + name: 'core/test-ribs', + }, { + clientId: 'chicken', + name: 'core/test-chicken', + } ]; + + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getSettings', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-ribs', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + args: [ 'core/test-chicken', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( false ), + ).toEqual( { + value: undefined, + done: true, + } ); + } ); + + it( 'should yield the REPLACE_BLOCKS action if the all the replacement blocks can be inserted in the parent block', () => { + const blocks = [ { + clientId: 'ribs', + name: 'core/test-ribs', + }, { + clientId: 'chicken', + name: 'core/test-chicken', + } ]; + + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + + // Skip getSettings select. + replaceBlockGenerator.next(); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-ribs', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + args: [ 'core/test-chicken', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks, + time: expect.any( Number ), + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); + } ); + } ); + + describe( 'insertBlock', () => { + it( 'should yield the INSERT_BLOCKS action', () => { + const block = { + clientId: 'ribs', + name: 'core/test-block', + }; + const index = 5; + + const insertBlockGenerator = insertBlock( block, index, 'testclientid', true ); + + // Skip getSettings select. + insertBlockGenerator.next(); + + expect( + insertBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-block', 'testclientid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlockGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ block ], + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + }, + } ); + } ); + } ); + + describe( 'insertBlocks', () => { + it( 'should apply default styles to blocks if blocks do not contain a style', () => { + const ribsBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const chickenRibsBlock = { + clientId: 'chicken-ribs', + name: 'core/test-chicken-ribs', + }; + const blocks = [ + ribsBlock, + chickenBlock, + chickenRibsBlock, + ]; + + const insertBlocksGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + insertBlocksGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getSettings', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( { + __experimentalPreferredStyleVariations: { + value: { + 'core/test-ribs': 'squared', + 'core/test-chicken-ribs': 'colorful', + }, + }, + } ).value, + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ).value + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ).value, + ).toEqual( { + args: [ 'core/test-chicken-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ + { ...ribsBlock, attributes: { className: 'is-style-squared' } }, + chickenBlock, + { ...chickenRibsBlock, attributes: { className: 'is-style-colorful' } }, + ], + index: 5, + rootClientId: 'testrootid', + time: expect.any( Number ), + updateSelection: false, + }, + } ); + } ); + + it( 'should keep styles explicitly set even if different from the default', () => { + const ribsWithStyleBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + attributes: { + className: 'is-style-colorful', + }, + }; + const blocks = [ + ribsWithStyleBlock, + ]; + + const insertBlocksGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + insertBlocksGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getSettings', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( { + __experimentalPreferredStyleVariations: { + value: { + 'core/test-ribs': 'squared', + }, + }, + } ).value, + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ + { ...ribsWithStyleBlock, attributes: { className: 'is-style-colorful' } }, + ], + index: 5, + rootClientId: 'testrootid', + time: expect.any( Number ), + updateSelection: false, + }, + } ); + } ); + it( 'should filter the allowed blocks in INSERT_BLOCKS action', () => { + const ribsBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const chickenRibsBlock = { + clientId: 'chicken-ribs', + name: 'core/test-chicken-ribs', + }; + const blocks = [ + ribsBlock, + chickenBlock, + chickenRibsBlock, + ]; + + const insertBlocksGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + // Skip getSettings select. + insertBlocksGenerator.next(); + + expect( + insertBlocksGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ).value + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ ribsBlock, chickenRibsBlock ], + index: 5, + rootClientId: 'testrootid', + time: expect.any( Number ), + updateSelection: false, + }, + } ); + } ); + + it( 'does not yield INSERT_BLOCKS action if all the blocks are impossible to insert', () => { + const ribsBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const blocks = [ + ribsBlock, + chickenBlock, + ]; + + const insertBlocksGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + // Skip getSettings select. + insertBlocksGenerator.next(); + + expect( + insertBlocksGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + insertBlocksGenerator.next( false ), + ).toEqual( { + done: true, + value: undefined, + } ); + } ); + } ); + + describe( 'showInsertionPoint', () => { + it( 'should return the SHOW_INSERTION_POINT action', () => { + expect( showInsertionPoint() ).toEqual( { + type: 'SHOW_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'hideInsertionPoint', () => { + it( 'should return the HIDE_INSERTION_POINT action', () => { + expect( hideInsertionPoint() ).toEqual( { + type: 'HIDE_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'mergeBlocks', () => { + it( 'should return MERGE_BLOCKS action', () => { + const firstBlockClientId = 'blockA'; + const secondBlockClientId = 'blockB'; + expect( mergeBlocks( firstBlockClientId, secondBlockClientId ) ).toEqual( { + type: 'MERGE_BLOCKS', + blocks: [ firstBlockClientId, secondBlockClientId ], + } ); + } ); + } ); + + describe( 'removeBlocks', () => { + it( 'should return REMOVE_BLOCKS action', () => { + const clientId = 'clientId'; + const clientIds = [ clientId ]; + + const actions = Array.from( removeBlocks( clientIds ) ); + + expect( actions ).toEqual( [ + selectPreviousBlock( clientId ), + { + type: 'REMOVE_BLOCKS', + clientIds, + }, + select( + 'core/block-editor', + 'getBlockCount', + ), + ] ); + } ); + } ); + + describe( 'moveBlockToPosition', () => { + it( 'should yield MOVE_BLOCK_TO_POSITION action if locking is insert and move is not changing the root block', () => { + const moveBlockToPositionGenerator = moveBlockToPosition( + 'chicken', + 'ribs', + 'ribs', + 5 + ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'ribs' ], + selectorName: 'getTemplateLock', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( 'insert' ).value + ).toEqual( { + type: 'MOVE_BLOCK_TO_POSITION', + fromRootClientId: 'ribs', + toRootClientId: 'ribs', + clientId: 'chicken', + index: 5, + } ); + + expect( + moveBlockToPositionGenerator.next().done + ).toBe( true ); + } ); + + it( 'should not yield MOVE_BLOCK_TO_POSITION action if locking is all', () => { + const moveBlockToPositionGenerator = moveBlockToPosition( + 'chicken', + 'ribs', + 'ribs', + 5 + ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'ribs' ], + selectorName: 'getTemplateLock', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( 'all' ) + ).toEqual( { + done: true, + value: undefined, + } ); + } ); + + it( 'should not yield MOVE_BLOCK_TO_POSITION action if locking is insert and move is changing the root block', () => { + const moveBlockToPositionGenerator = moveBlockToPosition( + 'chicken', + 'ribs', + 'chicken-ribs', + 5 + ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'ribs' ], + selectorName: 'getTemplateLock', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( 'insert' ) + ).toEqual( { + done: true, + value: undefined, + } ); + } ); + + it( 'should yield MOVE_BLOCK_TO_POSITION action if there is not locking in the original root block and block can be inserted in the destination', () => { + const moveBlockToPositionGenerator = moveBlockToPosition( 'chicken', + 'ribs', + 'chicken-ribs', + 5 + ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'ribs' ], + selectorName: 'getTemplateLock', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockName', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( 'myblock/chicken-block' ).value + ).toEqual( { + args: [ 'myblock/chicken-block', 'chicken-ribs' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( true ).value + ).toEqual( { + type: 'MOVE_BLOCK_TO_POSITION', + fromRootClientId: 'ribs', + toRootClientId: 'chicken-ribs', + clientId: 'chicken', + index: 5, + } ); + + expect( + moveBlockToPositionGenerator.next() + ).toEqual( { + done: true, + value: undefined, + } ); + } ); + + it( 'should not yield MOVE_BLOCK_TO_POSITION action if there is not locking in the original root block and block can be inserted in the destination', () => { + const moveBlockToPositionGenerator = moveBlockToPosition( 'chicken', + 'ribs', + 'chicken-ribs', + 5 + ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'ribs' ], + selectorName: 'getTemplateLock', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next().value + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockName', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( 'myblock/chicken-block' ).value + ).toEqual( { + args: [ 'myblock/chicken-block', 'chicken-ribs' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + moveBlockToPositionGenerator.next( false ) + ).toEqual( { + done: true, + value: undefined, + } ); + } ); + } ); + + describe( 'removeBlock', () => { + it( 'should return REMOVE_BLOCKS action', () => { + const clientId = 'myclientid'; + + const actions = Array.from( removeBlock( clientId ) ); + + expect( actions ).toEqual( [ + selectPreviousBlock( clientId ), + { + type: 'REMOVE_BLOCKS', + clientIds: [ clientId ], + }, + select( + 'core/block-editor', + 'getBlockCount', + ), + ] ); + } ); + + it( 'should return REMOVE_BLOCKS action, opting out of select previous', () => { + const clientId = 'myclientid'; + + const actions = Array.from( removeBlock( clientId, false ) ); + + expect( actions ).toEqual( [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ clientId ], + }, + select( + 'core/block-editor', + 'getBlockCount', + ), + ] ); + } ); + } ); + + describe( 'toggleBlockMode', () => { + it( 'should return TOGGLE_BLOCK_MODE action', () => { + const clientId = 'myclientid'; + expect( toggleBlockMode( clientId ) ).toEqual( { + type: 'TOGGLE_BLOCK_MODE', + clientId, + } ); + } ); + } ); + + describe( 'startTyping', () => { + it( 'should return the START_TYPING action', () => { + expect( startTyping() ).toEqual( { + type: 'START_TYPING', + } ); + } ); + } ); + + describe( 'stopTyping', () => { + it( 'should return the STOP_TYPING action', () => { + expect( stopTyping() ).toEqual( { + type: 'STOP_TYPING', + } ); + } ); + } ); + + describe( 'enterFormattedText', () => { + it( 'should return the ENTER_FORMATTED_TEXT action', () => { + expect( enterFormattedText() ).toEqual( { + type: 'ENTER_FORMATTED_TEXT', + } ); + } ); + } ); + + describe( 'exitFormattedText', () => { + it( 'should return the EXIT_FORMATTED_TEXT action', () => { + expect( exitFormattedText() ).toEqual( { + type: 'EXIT_FORMATTED_TEXT', + } ); + } ); + } ); + + describe( 'toggleSelection', () => { + it( 'should return the TOGGLE_SELECTION action with default value for isSelectionEnabled = true', () => { + expect( toggleSelection() ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: true, + } ); + } ); + + it( 'should return the TOGGLE_SELECTION action with isSelectionEnabled = true as passed in the argument', () => { + expect( toggleSelection( true ) ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: true, + } ); + } ); + + it( 'should return the TOGGLE_SELECTION action with isSelectionEnabled = false as passed in the argument', () => { + expect( toggleSelection( false ) ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: false, + } ); + } ); + } ); + + describe( 'updateBlockListSettings', () => { + it( 'should return the UPDATE_BLOCK_LIST_SETTINGS with undefined settings', () => { + expect( updateBlockListSettings( 'chicken' ) ).toEqual( { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'chicken', + settings: undefined, + } ); + } ); + + it( 'should return the UPDATE_BLOCK_LIST_SETTINGS action with the passed settings', () => { + expect( updateBlockListSettings( 'chicken', { chicken: 'ribs' } ) ).toEqual( { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'chicken', + settings: { chicken: 'ribs' }, + } ); + } ); + } ); + + describe( 'replaceInnerBlocks', () => { + const block = { + clientId: 'ribs', + }; + + it( 'should return the REPLACE_INNER_BLOCKS action with default values set', () => { + expect( replaceInnerBlocks( 'root', [ block ] ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + + it( 'should return the REPLACE_INNER_BLOCKS action with updateSelection false', () => { + expect( replaceInnerBlocks( 'root', [ block ], false ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: false, + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/store/test/array.js b/gb-src/packages/block-editor/src/store/test/array.js new file mode 100644 index 0000000000000..5edcad46ad80f --- /dev/null +++ b/gb-src/packages/block-editor/src/store/test/array.js @@ -0,0 +1,45 @@ +/** + * Internal dependencies + */ +import { insertAt, moveTo } from '../array'; + +describe( 'array', () => { + describe( 'insertAt', () => { + it( 'should insert a unique item at a given position', () => { + const array = [ 'a', 'b', 'd' ]; + expect( insertAt( array, 'c', 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should insert multiple items at a given position', () => { + const array = [ 'a', 'b', 'e' ]; + expect( insertAt( array, [ 'c', 'd' ], 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd', 'e' ] + ); + } ); + } ); + + describe( 'moveTo', () => { + it( 'should move an item to a given position', () => { + const array = [ 'a', 'b', 'd', 'c' ]; + expect( moveTo( array, 3, 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should move an item upwards to a given position', () => { + const array = [ 'b', 'a', 'c', 'd' ]; + expect( moveTo( array, 0, 1 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should move multiple items to a given position', () => { + const array = [ 'a', 'c', 'd', 'b', 'e' ]; + expect( moveTo( array, 1, 2, 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd', 'e' ] + ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/store/test/effects.js b/gb-src/packages/block-editor/src/store/test/effects.js new file mode 100644 index 0000000000000..827dde0d39b5c --- /dev/null +++ b/gb-src/packages/block-editor/src/store/test/effects.js @@ -0,0 +1,345 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import deepFreeze from 'deep-freeze'; + +/** + * WordPress dependencies + */ +import { + getBlockTypes, + unregisterBlockType, + registerBlockType, + createBlock, +} from '@wordpress/blocks'; +import { createRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import actions, { + updateSettings, + mergeBlocks, + replaceBlocks, + resetBlocks, + selectBlock, + selectionChange, + setTemplateValidity, +} from '../actions'; +import effects, { validateBlocksToTemplate } from '../effects'; +import * as selectors from '../selectors'; +import reducer from '../reducer'; +import applyMiddlewares from '../middlewares'; +import '../../'; + +describe( 'effects', () => { + const defaultBlockSettings = { + attributes: { + content: {}, + }, + save: () => 'Saved', + category: 'common', + title: 'block title', + }; + + describe( '.MERGE_BLOCKS', () => { + const handler = effects.MERGE_BLOCKS; + const defaultGetBlock = selectors.getBlock; + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + selectors.getBlock = defaultGetBlock; + } ); + + it( 'should only focus the blockA if the blockA has no merge function', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + const blockA = deepFreeze( { + clientId: 'chicken', + name: 'core/test-block', + } ); + const blockB = deepFreeze( { + clientId: 'ribs', + name: 'core/test-block', + } ); + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + + const dispatch = jest.fn(); + const getState = () => ( {} ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken' ) ); + } ); + + it( 'should merge the blocks if blocks of the same type', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: {}, + }, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const blockA = deepFreeze( { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + innerBlocks: [], + } ); + const blockB = deepFreeze( { + clientId: 'ribs', + name: 'core/test-block', + attributes: { content: 'ribs' }, + innerBlocks: [], + } ); + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( { + blockSelection: { + start: { + clientId: blockB.clientId, + attributeKey: 'content', + offset: 0, + }, + }, + } ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( selectionChange( + blockA.clientId, + 'content', + 'chicken'.length + 1, + 'chicken'.length + 1, + ) ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); + } ); + + it( 'should not merge the blocks have different types without transformation', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: {}, + }, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block-2', defaultBlockSettings ); + const blockA = deepFreeze( { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + innerBlocks: [], + } ); + const blockB = deepFreeze( { + clientId: 'ribs', + name: 'core/test-block-2', + attributes: { content: 'ribs' }, + innerBlocks: [], + } ); + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( { + blockSelection: { + start: { + clientId: blockB.clientId, + attributeKey: 'content', + offset: 0, + }, + }, + } ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should transform and merge the blocks', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: { + type: 'string', + }, + }, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block-2', { + attributes: { + content2: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/test-block' ], + transform: ( { content2 } ) => { + return createBlock( 'core/test-block', { + content: content2, + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'test block 2', + } ); + const blockA = deepFreeze( { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + innerBlocks: [], + } ); + const blockB = deepFreeze( { + clientId: 'ribs', + name: 'core/test-block-2', + attributes: { content2: 'ribs' }, + innerBlocks: [], + } ); + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( { + blockSelection: { + start: { + clientId: blockB.clientId, + attributeKey: 'content2', + offset: 0, + }, + }, + } ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( selectionChange( + blockA.clientId, + 'content', + 'chicken'.length + 1, + 'chicken'.length + 1, + ) ); + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); + } ); + } ); + + describe( 'validateBlocksToTemplate', () => { + let store; + beforeEach( () => { + store = createRegistry().registerStore( 'test', { + actions, + selectors, + reducer, + } ); + applyMiddlewares( store ); + + registerBlockType( 'core/test-block', defaultBlockSettings ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'should return undefined if no template assigned', () => { + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return undefined if invalid but unlocked', () => { + store.dispatch( updateSettings( { + template: [ + [ 'core/foo', {} ], + ], + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return undefined if locked and valid', () => { + store.dispatch( updateSettings( { + template: [ + [ 'core/test-block' ], + ], + templateLock: 'all', + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return validity set action if invalid on default state', () => { + store.dispatch( updateSettings( { + template: [ + [ 'core/foo' ], + ], + templateLock: 'all', + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toEqual( setTemplateValidity( false ) ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/store/test/reducer.js b/gb-src/packages/block-editor/src/store/test/reducer.js new file mode 100644 index 0000000000000..6eb56576f71b2 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/test/reducer.js @@ -0,0 +1,2470 @@ +/** + * External dependencies + */ +import { values, noop } from 'lodash'; +import deepFreeze from 'deep-freeze'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + createBlock, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + hasSameKeys, + isUpdatingSameBlockAttribute, + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + preferences, + blocksMode, + insertionPoint, + template, + blockListSettings, + lastBlockAttributesChange, +} from '../reducer'; + +describe( 'state', () => { + describe( 'hasSameKeys()', () => { + it( 'returns false if two objects do not have the same keys', () => { + const a = { foo: 10 }; + const b = { bar: 10 }; + + expect( hasSameKeys( a, b ) ).toBe( false ); + } ); + + it( 'returns false if two objects have the same keys', () => { + const a = { foo: 10 }; + const b = { foo: 20 }; + + expect( hasSameKeys( a, b ) ).toBe( true ); + } ); + } ); + + describe( 'isUpdatingSameBlockAttribute()', () => { + it( 'should return false if not updating block attributes', () => { + const action = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + const previousAction = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if last action was not updating block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if not updating the same block', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + attributes: { + foo: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if not updating the same block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + bar: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if no previous action', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = undefined; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return true if updating the same block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( true ); + } ); + } ); + + describe( 'blocks()', () => { + beforeAll( () => { + registerBlockType( 'core/test-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test block', + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + } ); + + describe( 'replace inner blocks', () => { + beforeAll( () => { + registerBlockType( 'core/test-parent-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test parent block', + } ); + registerBlockType( 'core/test-child-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test child block 1', + attributes: { + attr: { + type: 'boolean', + }, + attr2: { + type: 'string', + }, + }, + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-parent-block' ); + unregisterBlockType( 'core/test-child-block' ); + } ); + it( 'can replace a child block', () => { + const existingState = deepFreeze( { + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'chicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + 'chicken-child': { + attr: true, + }, + }, + order: { + '': [ 'chicken' ], + chicken: [ 'chicken-child' ], + 'chicken-child': [], + }, + parents: { + chicken: '', + 'chicken-child': 'chicken', + }, + cache: { + chicken: {}, + 'chicken-child': {}, + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'chicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'chicken' ], + chicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + parents: { + [ newChildBlockId ]: 'chicken', + chicken: '', + }, + cache: { + chicken: {}, + [ newChildBlockId ]: {}, + }, + } ); + expect( state.cache.chicken ).not.toBe( existingState.cache.chicken ); + } ); + + it( 'can insert a child block', () => { + const existingState = deepFreeze( { + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + }, + order: { + '': [ 'chicken' ], + chicken: [], + }, + parents: { + chicken: '', + }, + cache: { + chicken: {}, + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'chicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'chicken' ], + chicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + parents: { + [ newChildBlockId ]: 'chicken', + chicken: '', + }, + cache: { + chicken: {}, + [ newChildBlockId ]: {}, + }, + } ); + expect( state.cache.chicken ).not.toBe( existingState.cache.chicken ); + } ); + + it( 'can replace multiple child blocks', () => { + const existingState = deepFreeze( { + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'chicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'chicken-child-2': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + 'chicken-child': { + attr: true, + }, + 'chicken-child-2': { + attr2: 'ok', + }, + }, + order: { + '': [ 'chicken' ], + chicken: [ 'chicken-child', 'chicken-child-2' ], + 'chicken-child': [], + 'chicken-child-2': [], + }, + parents: { + chicken: '', + 'chicken-child': 'chicken', + 'chicken-child-2': 'chicken', + }, + cache: { + chicken: {}, + 'chicken-child': {}, + 'chicken-child-2': {}, + }, + } ); + + const newChildBlock1 = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const newChildBlock2 = createBlock( 'core/test-child-block', { + attr: true, + attr2: 'not-perfect', + } ); + + const newChildBlock3 = createBlock( 'core/test-child-block', { + attr2: 'hello', + } ); + + const { clientId: newChildBlockId1 } = newChildBlock1; + const { clientId: newChildBlockId2 } = newChildBlock2; + const { clientId: newChildBlockId3 } = newChildBlock3; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'chicken', + blocks: [ newChildBlock1, newChildBlock2, newChildBlock3 ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId1 ]: { + clientId: newChildBlockId1, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId2 ]: { + clientId: newChildBlockId2, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId3 ]: { + clientId: newChildBlockId3, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + [ newChildBlockId1 ]: { + attr: false, + attr2: 'perfect', + }, + [ newChildBlockId2 ]: { + attr: true, + attr2: 'not-perfect', + }, + [ newChildBlockId3 ]: { + attr2: 'hello', + }, + }, + order: { + '': [ 'chicken' ], + chicken: [ newChildBlockId1, newChildBlockId2, newChildBlockId3 ], + [ newChildBlockId1 ]: [], + [ newChildBlockId2 ]: [], + [ newChildBlockId3 ]: [], + }, + parents: { + chicken: '', + [ newChildBlockId1 ]: 'chicken', + [ newChildBlockId2 ]: 'chicken', + [ newChildBlockId3 ]: 'chicken', + }, + cache: { + chicken: {}, + [ newChildBlockId1 ]: {}, + [ newChildBlockId2 ]: {}, + [ newChildBlockId3 ]: {}, + }, + } ); + } ); + + it( 'can replace a child block that has other children', () => { + const existingState = deepFreeze( { + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'chicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'chicken-grand-child': { + clientId: 'chicken-child', + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + 'chicken-child': {}, + 'chicken-grand-child': {}, + }, + order: { + '': [ 'chicken' ], + chicken: [ 'chicken-child' ], + 'chicken-child': [ 'chicken-grand-child' ], + 'chicken-grand-child': [], + }, + parents: { + chicken: '', + 'chicken-child': 'chicken', + 'chicken-grand-child': 'chicken-child', + }, + cache: { + chicken: {}, + 'chicken-child': {}, + 'chicken-grand-child': {}, + }, + } ); + + const newChildBlock = createBlock( 'core/test-block' ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'chicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + chicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + chicken: {}, + [ newChildBlockId ]: {}, + }, + order: { + '': [ 'chicken' ], + chicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + parents: { + chicken: '', + [ newChildBlockId ]: 'chicken', + }, + cache: { + chicken: {}, + [ newChildBlockId ]: {}, + }, + } ); + + // the cache key of the parent should be updated + expect( existingState.cache.chicken ).not.toBe( state.cache.chicken ); + } ); + } ); + + it( 'should return empty byClientId, attributes, order by default', () => { + const state = blocks( undefined, {} ); + + expect( state ).toEqual( { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + isPersistentChange: true, + isIgnoredChange: false, + cache: {}, + } ); + } ); + + it( 'should key by reset blocks clientId', () => { + [ + undefined, + blocks( undefined, {} ), + ].forEach( ( original ) => { + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ { clientId: 'bananas', innerBlocks: [] } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 1 ); + expect( values( state.byClientId )[ 0 ].clientId ).toBe( 'bananas' ); + expect( state.order ).toEqual( { + '': [ 'bananas' ], + bananas: [], + } ); + expect( state.cache ).toEqual( { + bananas: {}, + } ); + } ); + } ); + + it( 'should key by reset blocks clientId, including inner blocks', () => { + const original = blocks( undefined, {} ); + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'bananas', + innerBlocks: [ { clientId: 'apples', innerBlocks: [] } ], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 2 ); + expect( state.order ).toEqual( { + '': [ 'bananas' ], + apples: [], + bananas: [ 'apples' ], + } ); + expect( state.cache ).toEqual( { + bananas: {}, + apples: {}, + } ); + } ); + + it( 'should insert block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 2 ); + expect( values( state.byClientId )[ 1 ].clientId ).toBe( 'ribs' ); + expect( state.order ).toEqual( { + '': [ 'chicken', 'ribs' ], + chicken: [], + ribs: [], + } ); + expect( state.cache ).toEqual( { + chicken: {}, + ribs: {}, + } ); + // The cache key is the same because the block has not been modified. + expect( original.cache.chicken ).toBe( state.cache.chicken ); + } ); + + it( 'should replace the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 1 ); + expect( values( state.byClientId )[ 0 ].name ).toBe( 'core/freeform' ); + expect( values( state.byClientId )[ 0 ].clientId ).toBe( 'wings' ); + expect( state.order ).toEqual( { + '': [ 'wings' ], + wings: [], + } ); + expect( state.parents ).toEqual( { + wings: '', + } ); + expect( state.cache ).toEqual( { + wings: {}, + } ); + } ); + it( 'should replace the block and remove references to its inner blocks', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [ + { + clientId: 'child', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, + ], + } ], + } ); + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 1 ); + expect( state.order ).toEqual( { + '': [ 'wings' ], + wings: [], + } ); + expect( state.parents ).toEqual( { + wings: '', + } ); + expect( state.cache ).toEqual( { + wings: {}, + } ); + } ); + + it( 'should replace the nested block', () => { + const nestedBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ nestedBlock ] ); + const replacementBlock = createBlock( 'core/test-block' ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + + const originalWrapperBlockCacheKey = original.cache[ wrapperBlock.clientId ]; + + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ nestedBlock.clientId ], + blocks: [ replacementBlock ], + } ); + + const newWrapperBlockCacheKey = state.cache[ wrapperBlock.clientId ]; + + expect( newWrapperBlockCacheKey ).not.toBe( originalWrapperBlockCacheKey ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ replacementBlock.clientId ], + [ replacementBlock.clientId ]: [], + } ); + + expect( state.parents ).toEqual( { + [ wrapperBlock.clientId ]: '', + [ replacementBlock.clientId ]: wrapperBlock.clientId, + } ); + + expect( state.cache ).toEqual( { + [ wrapperBlock.clientId ]: {}, + [ replacementBlock.clientId ]: {}, + } ); + } ); + + it( 'should replace the block even if the new block clientId is the same', () => { + const originalState = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const replacedState = blocks( originalState, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'chicken', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( replacedState.byClientId ) ).toHaveLength( 1 ); + expect( values( originalState.byClientId )[ 0 ].name ).toBe( 'core/test-block' ); + expect( values( replacedState.byClientId )[ 0 ].name ).toBe( 'core/freeform' ); + expect( values( replacedState.byClientId )[ 0 ].clientId ).toBe( 'chicken' ); + expect( replacedState.order ).toEqual( { + '': [ 'chicken' ], + chicken: [], + } ); + expect( replacedState.cache ).toEqual( { + chicken: {}, + } ); + expect( originalState.cache.chicken ).not.toBe( replacedState.cache.chicken ); + + const nestedBlock = { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }; + const wrapperBlock = createBlock( 'core/test-block', {}, [ nestedBlock ] ); + const replacementNestedBlock = { + clientId: 'chicken', + name: 'core/freeform', + attributes: {}, + innerBlocks: [], + }; + + const originalNestedState = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + + const replacedNestedState = blocks( originalNestedState, { + type: 'REPLACE_BLOCKS', + clientIds: [ nestedBlock.clientId ], + blocks: [ replacementNestedBlock ], + } ); + + expect( replacedNestedState.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ replacementNestedBlock.clientId ], + [ replacementNestedBlock.clientId ]: [], + } ); + + expect( originalNestedState.byClientId.chicken.name ).toBe( 'core/test-block' ); + expect( replacedNestedState.byClientId.chicken.name ).toBe( 'core/freeform' ); + } ); + + it( 'should update the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + isValid: false, + innerBlocks: [], + } ], + } ); + const state = blocks( deepFreeze( original ), { + type: 'UPDATE_BLOCK', + clientId: 'chicken', + updates: { + attributes: { content: 'ribs' }, + isValid: true, + }, + } ); + + expect( state.byClientId.chicken ).toEqual( { + clientId: 'chicken', + name: 'core/test-block', + isValid: true, + } ); + + expect( state.attributes.chicken ).toEqual( { + content: 'ribs', + } ); + + expect( state.cache ).toEqual( { + chicken: {}, + } ); + expect( state.cache.chicken ).not.toBe( original.cache.chicken ); + } ); + + it( 'should update the reusable block reference if the temporary id is swapped', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/block', + attributes: { + ref: 'random-clientId', + }, + isValid: false, + innerBlocks: [], + } ], + } ); + + const state = blocks( deepFreeze( original ), { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id: 'random-clientId', + updatedId: 3, + } ); + + expect( state.byClientId.chicken ).toEqual( { + clientId: 'chicken', + name: 'core/block', + isValid: false, + } ); + + expect( state.attributes.chicken ).toEqual( { + ref: 3, + } ); + expect( state.cache ).toEqual( { + chicken: {}, + } ); + expect( state.cache.chicken ).not.toBe( original.cache.chicken ); + } ); + + it( 'should move the block up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + expect( state.cache.ribs ).toBe( original.cache.ribs ); + expect( state.cache.chicken ).toBe( original.cache.chicken ); + } ); + + it( 'should move the nested block up', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ movedBlock.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ movedBlock.clientId, siblingBlock.clientId ], + [ movedBlock.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + expect( state.cache[ wrapperBlock.clientId ] ).not.toBe( original.cache[ wrapperBlock.clientId ] ); + expect( state.cache[ movedBlock.clientId ] ).toBe( original.cache[ movedBlock.clientId ] ); + expect( state.cache[ siblingBlock.clientId ] ).toBe( original.cache[ siblingBlock.clientId ] ); + } ); + + it( 'should move multiple blocks up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs', 'veggies' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + } ); + + it( 'should move multiple nested blocks up', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlockA, movedBlockB ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ movedBlockA.clientId, movedBlockB.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ movedBlockA.clientId, movedBlockB.clientId, siblingBlock.clientId ], + [ movedBlockA.clientId ]: [], + [ movedBlockB.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should not move the first block up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'chicken' ], + } ); + + expect( state.order ).toBe( original.order ); + } ); + + it( 'should move the block down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'chicken' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move the nested block down', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlock, siblingBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ movedBlock.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ siblingBlock.clientId, movedBlock.clientId ], + [ movedBlock.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should move multiple blocks down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'chicken', 'ribs' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + } ); + + it( 'should move multiple nested blocks down', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlockA, movedBlockB, siblingBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ movedBlockA.clientId, movedBlockB.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ siblingBlock.clientId, movedBlockA.clientId, movedBlockB.clientId ], + [ movedBlockA.clientId ]: [], + [ movedBlockB.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should not move the last block down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'ribs' ], + } ); + + expect( state.order ).toBe( original.order ); + } ); + + it( 'should remove the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.order ).not.toHaveProperty( 'chicken' ); + expect( state.parents ).toEqual( { + ribs: '', + } ); + expect( state.byClientId ).toEqual( { + ribs: { + clientId: 'ribs', + name: 'core/test-block', + }, + } ); + expect( state.attributes ).toEqual( { + ribs: {}, + } ); + expect( state.cache ).toEqual( { + ribs: {}, + } ); + } ); + + it( 'should remove multiple blocks', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken', 'veggies' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.order ).not.toHaveProperty( 'chicken' ); + expect( state.order ).not.toHaveProperty( 'veggies' ); + expect( state.parents ).toEqual( { + ribs: '', + } ); + expect( state.byClientId ).toEqual( { + ribs: { + clientId: 'ribs', + name: 'core/test-block', + }, + } ); + expect( state.attributes ).toEqual( { + ribs: {}, + } ); + } ); + + it( 'should cascade remove to include inner blocks', () => { + const block = createBlock( 'core/test-block', {}, [ + createBlock( 'core/test-block', {}, [ + createBlock( 'core/test-block' ), + ] ), + ] ); + + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ block ], + } ); + + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ block.clientId ], + } ); + + expect( state.byClientId ).toEqual( {} ); + expect( state.order ).toEqual( { + '': [], + } ); + expect( state.parents ).toEqual( {} ); + } ); + + it( 'should insert at the specified index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'loquat', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + + const state = blocks( original, { + type: 'INSERT_BLOCKS', + index: 1, + blocks: [ { + clientId: 'persimmon', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 3 ); + expect( state.order[ '' ] ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); + } ); + + it( 'should move block to lower index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 0, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken', 'veggies' ] ); + } ); + + it( 'should move block to higher index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 2, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'chicken', 'veggies', 'ribs' ] ); + } ); + + it( 'should not move block if passed same index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 1, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'chicken', 'ribs', 'veggies' ] ); + } ); + + describe( 'blocks', () => { + it( 'should not reset any blocks that are not in the post', () => { + const actions = [ + { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block1', + innerBlocks: [ + { clientId: 'block11', innerBlocks: [] }, + { clientId: 'block12', innerBlocks: [] }, + ], + }, + ], + }, + { + type: 'RECEIVE_BLOCKS', + blocks: [ + { + clientId: 'block2', + innerBlocks: [ + { clientId: 'block21', innerBlocks: [] }, + { clientId: 'block22', innerBlocks: [] }, + ], + }, + ], + }, + ]; + const original = deepFreeze( actions.reduce( blocks, undefined ) ); + + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block3', + innerBlocks: [ + { clientId: 'block31', innerBlocks: [] }, + { clientId: 'block32', innerBlocks: [] }, + ], + }, + ], + } ); + + expect( state.byClientId ).toEqual( { + block2: { clientId: 'block2' }, + block21: { clientId: 'block21' }, + block22: { clientId: 'block22' }, + block3: { clientId: 'block3' }, + block31: { clientId: 'block31' }, + block32: { clientId: 'block32' }, + } ); + } ); + + describe( 'byClientId', () => { + it( 'should ignore updates to non-existent block', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.byClientId ).toBe( original.byClientId ); + } ); + + it( 'should return with same reference if no changes in updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.byClientId ).toBe( state.byClientId ); + } ); + } ); + + describe( 'attributes', () => { + it( 'should return with attribute block updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes.kumquat.updated ).toBe( true ); + } ); + + it( 'should accumulate attribute block updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + moreUpdated: true, + }, + } ); + + expect( state.attributes.kumquat ).toEqual( { + updated: true, + moreUpdated: true, + } ); + } ); + + it( 'should ignore updates to non-existent block', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes ).toBe( original.attributes ); + } ); + + it( 'should return with same reference if no changes in updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes ).toBe( state.attributes ); + } ); + } ); + + describe( 'isPersistentChange', () => { + it( 'should default a changing state to true', () => { + const state = deepFreeze( blocks( undefined, {} ) ); + + expect( state.isPersistentChange ).toBe( true ); + } ); + + it( 'should consider any non-exempt block change as persistent', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.isPersistentChange ).toBe( true ); + } ); + + it( 'should consider any non-exempt block change as persistent across unchanging actions', () => { + let original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + original = blocks( original, { + type: 'NOOP', + } ); + original = blocks( original, { + // While RECEIVE_BLOCKS changes state, it's considered + // as ignored, confirmed by this test. + type: 'RECEIVE_BLOCKS', + blocks: [], + } ); + + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: false, + }, + } ); + + expect( state.isPersistentChange ).toBe( true ); + } ); + + it( 'should consider same block attribute update as exempt', () => { + let original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + original = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: false, + }, + } ); + + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.isPersistentChange ).toBe( false ); + } ); + + it( 'should flag an explicitly marked persistent change', () => { + let original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + original = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: false, + }, + } ); + original = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + const state = blocks( original, { + type: 'MARK_LAST_CHANGE_AS_PERSISTENT', + } ); + + expect( state.isPersistentChange ).toBe( true ); + } ); + + it( 'should retain reference for same state, same persistence', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + + const state = blocks( original, { + type: '__INERT__', + } ); + + expect( state ).toBe( original ); + } ); + } ); + + describe( 'isIgnoredChange', () => { + it( 'should consider received blocks as ignored change', () => { + const resetState = blocks( undefined, { type: 'random action' } ); + const state = blocks( resetState, { + type: 'RECEIVE_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ); + + expect( state.isIgnoredChange ).toBe( true ); + } ); + } ); + } ); + } ); + + describe( 'insertionPoint', () => { + it( 'should default to null', () => { + const state = insertionPoint( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'should set insertion point', () => { + const state = insertionPoint( null, { + type: 'SHOW_INSERTION_POINT', + rootClientId: 'clientId1', + index: 0, + } ); + + expect( state ).toEqual( { + rootClientId: 'clientId1', + index: 0, + } ); + } ); + + it( 'should clear the insertion point', () => { + const original = deepFreeze( { + rootClientId: 'clientId1', + index: 0, + } ); + const state = insertionPoint( original, { + type: 'HIDE_INSERTION_POINT', + } ); + + expect( state ).toBe( null ); + } ); + } ); + + describe( 'isTyping()', () => { + it( 'should set the typing flag to true', () => { + const state = isTyping( false, { + type: 'START_TYPING', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the typing flag to false', () => { + const state = isTyping( false, { + type: 'STOP_TYPING', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'isCaretWithinFormattedText()', () => { + it( 'should set the flag to true', () => { + const state = isCaretWithinFormattedText( false, { + type: 'ENTER_FORMATTED_TEXT', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the flag to false', () => { + const state = isCaretWithinFormattedText( true, { + type: 'EXIT_FORMATTED_TEXT', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'blockSelection()', () => { + it( 'should return with block clientId as selected', () => { + const state = blockSelection( undefined, { + type: 'SELECT_BLOCK', + clientId: 'kumquat', + initialPosition: -1, + } ); + + expect( state ).toEqual( { + start: { clientId: 'kumquat' }, + end: { clientId: 'kumquat' }, + initialPosition: -1, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should set multi selection', () => { + const original = deepFreeze( { isMultiSelecting: false, initialPosition: null, isEnabled: true } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should set continuous multi selection', () => { + const original = deepFreeze( { isMultiSelecting: true, initialPosition: null, isEnabled: true } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: true, + isEnabled: true, + } ); + } ); + + it( 'should start multi selection', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'ribs' }, isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'ribs' }, + initialPosition: null, + isMultiSelecting: true, + } ); + } ); + + it( 'should return same reference if already multi-selecting', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'ribs' }, isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should end multi selection with selection', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'chicken' }, isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should return same reference if already ended multi-selecting', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'chicken' }, isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should end multi selection without selection', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'ribs' }, isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'ribs' }, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not update the state if the block is already selected', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'ribs' } } ); + + const state1 = blockSelection( original, { + type: 'SELECT_BLOCK', + clientId: 'ribs', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should unset multi selection', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'chicken' }, isEnabled: true } ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toEqual( { + start: {}, + end: {}, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should return same reference if clearing selection but no selection', () => { + const original = blockSelection( undefined, {} ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should select inserted block', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken', initialPosition: null, isEnabled: true, isMultiSelecting: false } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: true, + } ); + + expect( state3 ).toEqual( { + start: { clientId: 'ribs' }, + end: { clientId: 'ribs' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should not select inserted block if updateSelection flag is false', () => { + const original = deepFreeze( { start: { clientId: 'a' }, end: { clientId: 'b' } } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: false, + } ); + + expect( state3 ).toEqual( { + start: { clientId: 'a' }, + end: { clientId: 'b' }, + } ); + } ); + + it( 'should not update the state if the block moved is already selected', () => { + const original = deepFreeze( { start: { clientId: 'ribs' }, end: { clientId: 'ribs' } } ); + const state = blockSelection( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs' ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should replace the selected block', () => { + const original = deepFreeze( { start: { clientId: 'chicken' }, end: { clientId: 'chicken' }, initialPosition: null, isEnabled: true, isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: { clientId: 'wings' }, + end: { clientId: 'wings' }, + initialPosition: null, + isEnabled: true, + isMultiSelecting: false, + } ); + } ); + + it( 'should not replace the selected block if we keep it at the end when replacing blocks', () => { + const original = deepFreeze( { start: { clientId: 'wings' }, end: { clientId: 'wings' } } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'wings' ], + blocks: [ + { + clientId: 'chicken', + name: 'core/freeform', + }, + { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should replace the selected block if we keep it not at the end when replacing blocks', () => { + const original = deepFreeze( { start: { clientId: 'chicken' }, end: { clientId: 'chicken' }, initialPosition: null, isEnabled: true, isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ + { + clientId: 'chicken', + name: 'core/freeform', + }, + { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: { clientId: 'wings' }, + end: { clientId: 'wings' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should reset if replacing with empty set', () => { + const original = deepFreeze( { start: { clientId: 'chicken' }, end: { clientId: 'chicken' }, isEnabled: true } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [], + } ); + + expect( state ).toEqual( { + start: {}, + end: {}, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should keep the selected block', () => { + const original = deepFreeze( { start: { clientId: 'chicken' }, end: { clientId: 'chicken' } } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'ribs' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should remove the selection if we are removing the selected block', () => { + const original = deepFreeze( { + start: { clientId: 'chicken' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken' ], + } ); + + expect( state ).toEqual( { + start: {}, + end: {}, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should keep the selection if we are not removing the selected block', () => { + const original = deepFreeze( { + start: { clientId: 'chicken' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'ribs' ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should update the selection on inner blocks replace if updateSelection is true', () => { + const original = deepFreeze( { + start: { clientId: 'chicken' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: true, + } ); + + expect( state ).toEqual( { + start: { clientId: 'another-block' }, + end: { clientId: 'another-block' }, + initialPosition: null, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should not update the selection on inner blocks replace if updateSelection is false', () => { + const original = deepFreeze( { + start: { clientId: 'chicken' }, + end: { clientId: 'chicken' }, + initialPosition: null, + isMultiSelecting: false, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: false, + } ); + + expect( state ).toBe( original ); + } ); + } ); + + describe( 'preferences()', () => { + it( 'should apply all defaults', () => { + const state = preferences( undefined, {} ); + + expect( state ).toEqual( { + insertUsage: {}, + } ); + } ); + it( 'should record recently used blocks', () => { + const state = preferences( deepFreeze( { insertUsage: {} } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'bacon', + name: 'core-embed/twitter', + } ], + time: 123456, + } ); + + expect( state ).toEqual( { + insertUsage: { + 'core-embed/twitter': { + time: 123456, + count: 1, + insert: { name: 'core-embed/twitter' }, + }, + }, + } ); + + const twoRecentBlocks = preferences( deepFreeze( { + insertUsage: { + 'core-embed/twitter': { + time: 123456, + count: 1, + insert: { name: 'core-embed/twitter' }, + }, + }, + } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'eggs', + name: 'core-embed/twitter', + }, { + clientId: 'bacon', + name: 'core/block', + attributes: { ref: 123 }, + } ], + time: 123457, + } ); + + expect( twoRecentBlocks ).toEqual( { + insertUsage: { + 'core-embed/twitter': { + time: 123457, + count: 2, + insert: { name: 'core-embed/twitter' }, + }, + 'core/block/123': { + time: 123457, + count: 1, + insert: { name: 'core/block', ref: 123 }, + }, + }, + } ); + } ); + } ); + + describe( 'blocksMode', () => { + it( 'should set mode to html if not set', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + clientId: 'chicken', + }; + const value = blocksMode( deepFreeze( {} ), action ); + + expect( value ).toEqual( { chicken: 'html' } ); + } ); + + it( 'should toggle mode to visual if set as html', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + clientId: 'chicken', + }; + const value = blocksMode( deepFreeze( { chicken: 'html' } ), action ); + + expect( value ).toEqual( { chicken: 'visual' } ); + } ); + } ); + + describe( 'template', () => { + it( 'should default to visible', () => { + const state = template( undefined, {} ); + + expect( state ).toEqual( { isValid: true } ); + } ); + + it( 'should reset the validity flag', () => { + const original = deepFreeze( { isValid: false, template: [] } ); + const state = template( original, { + type: 'SET_TEMPLATE_VALIDITY', + isValid: true, + } ); + + expect( state ).toEqual( { isValid: true, template: [] } ); + } ); + } ); + + describe( 'blockListSettings', () => { + it( 'should add new settings', () => { + const original = deepFreeze( {} ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + } ); + + it( 'should return same reference if updated as the same', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should return same reference if updated settings not assigned and id not exists', () => { + const original = deepFreeze( {} ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should update the settings of a block', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/list' ], + }, + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/list' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + } ); + + it( 'should remove existing settings if updated settings not assigned', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + } ); + + expect( state ).toEqual( {} ); + } ); + + it( 'should remove the settings of a block when it is replaced', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + } ); + + it( 'should remove the settings of a block when it is removed', () => { + const original = deepFreeze( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + } ); + + expect( state ).toEqual( {} ); + } ); + } ); + + describe( 'lastBlockAttributesChange', () => { + it( 'defaults to null', () => { + const state = lastBlockAttributesChange( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'does not return updated value when block update does not include attributes', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + updates: {}, + } ); + + expect( state ).toBe( original ); + } ); + + it( 'returns updated value when block update includes attributes', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + updates: { + attributes: { + food: 'banana', + }, + }, + } ); + + expect( state ).toEqual( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + } ); + + it( 'returns updated value when explicit block attributes update', () => { + const original = null; + + const state = lastBlockAttributesChange( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + attributes: { + food: 'banana', + }, + } ); + + expect( state ).toEqual( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + } ); + + it( 'returns null on anything other than block attributes update', () => { + const original = deepFreeze( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { food: 'banana' }, + } ); + + const state = lastBlockAttributesChange( original, { + type: '__INERT__', + } ); + + expect( state ).toBe( null ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/store/test/selectors.js b/gb-src/packages/block-editor/src/store/test/selectors.js new file mode 100644 index 0000000000000..22ee0ed1adef3 --- /dev/null +++ b/gb-src/packages/block-editor/src/store/test/selectors.js @@ -0,0 +1,2341 @@ +/** + * External dependencies + */ +import { filter } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + setFreeformContentHandlerName, +} from '@wordpress/blocks'; +import { RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as selectors from '../selectors'; + +const { + getBlockName, + getBlock, + getBlocks, + getBlockCount, + getClientIdsWithDescendants, + getClientIdsOfDescendants, + hasSelectedBlock, + getSelectedBlock, + getSelectedBlockClientId, + getBlockRootClientId, + getBlockHierarchyRootClientId, + getGlobalBlockCount, + getSelectedBlockClientIds, + getMultiSelectedBlockClientIds, + getMultiSelectedBlocks, + getMultiSelectedBlocksStartClientId, + getMultiSelectedBlocksEndClientId, + getBlockOrder, + getBlockIndex, + getPreviousBlockClientId, + getNextBlockClientId, + isBlockSelected, + hasSelectedInnerBlock, + isBlockWithinSelection, + hasMultiSelection, + isBlockMultiSelected, + isFirstMultiSelectedBlock, + getBlockMode, + isTyping, + isCaretWithinFormattedText, + getBlockInsertionPoint, + isBlockInsertionPointVisible, + isSelectionEnabled, + canInsertBlockType, + getInserterItems, + isValidTemplate, + getTemplate, + getTemplateLock, + getBlockListSettings, + __experimentalGetLastBlockAttributeChanges, + INSERTER_UTILITY_HIGH, + INSERTER_UTILITY_MEDIUM, + INSERTER_UTILITY_LOW, +} = selectors; + +describe( 'selectors', () => { + let cachedSelectors; + + beforeAll( () => { + cachedSelectors = filter( selectors, ( selector ) => selector.clear ); + } ); + + beforeEach( () => { + registerBlockType( 'core/block', { + save: () => null, + category: 'reusable', + title: 'Reusable Block Stub', + supports: { + inserter: false, + }, + } ); + + registerBlockType( 'core/test-block-a', { + save: ( props ) => props.attributes.text, + category: 'formatting', + title: 'Test Block A', + icon: 'test', + keywords: [ 'testing' ], + } ); + + registerBlockType( 'core/test-block-b', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'Test Block B', + icon: 'test', + keywords: [ 'testing' ], + supports: { + multiple: false, + }, + } ); + + registerBlockType( 'core/test-block-c', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'Test Block C', + icon: 'test', + keywords: [ 'testing' ], + parent: [ 'core/test-block-b' ], + } ); + + registerBlockType( 'core/test-freeform', { + save: ( props ) => <RawHTML>{ props.attributes.content }</RawHTML>, + category: 'common', + title: 'Test Freeform Content Handler', + icon: 'test', + attributes: { + content: { + type: 'string', + }, + }, + } ); + + setFreeformContentHandlerName( 'core/test-freeform' ); + + cachedSelectors.forEach( ( { clear } ) => clear() ); + } ); + + afterEach( () => { + unregisterBlockType( 'core/block' ); + unregisterBlockType( 'core/test-block-a' ); + unregisterBlockType( 'core/test-block-b' ); + unregisterBlockType( 'core/test-block-c' ); + unregisterBlockType( 'core/test-freeform' ); + + setFreeformContentHandlerName( undefined ); + } ); + + describe( 'getBlockName', () => { + it( 'returns null if no block by clientId', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + }, + }; + + const name = getBlockName( state, 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ); + + expect( name ).toBe( null ); + } ); + + it( 'returns block name', () => { + const state = { + blocks: { + byClientId: { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + name: 'core/paragraph', + }, + }, + attributes: { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': {}, + }, + order: { + '': [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': [], + }, + parents: { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': '', + }, + }, + }; + + const name = getBlockName( state, 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ); + + expect( name ).toBe( 'core/paragraph' ); + } ); + } ); + + describe( 'getBlock', () => { + it( 'should return the block', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + }, + order: { + '': [ 123 ], + 123: [], + }, + parents: { + 123: '', + }, + cache: { + 123: {}, + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + } ); + } ); + + it( 'should return null if the block is not present in state', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + cache: {}, + }, + }; + + expect( getBlock( state, 123 ) ).toBe( null ); + } ); + + it( 'should include inner blocks', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/paragraph' }, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + }, + order: { + '': [ 123 ], + 123: [ 456 ], + 456: [], + }, + parents: { + 123: '', + 456: 123, + }, + cache: { + 123: {}, + 456: {}, + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [ { + clientId: 456, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + } ], + } ); + } ); + } ); + + describe( 'getBlocks', () => { + it( 'should return the ordered blocks', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 123, 23 ], + }, + parents: { + 123: '', + 23: '', + }, + cache: { + 123: {}, + 23: {}, + }, + }, + }; + + expect( getBlocks( state ) ).toEqual( [ + { clientId: 123, name: 'core/paragraph', attributes: {}, innerBlocks: [] }, + { clientId: 23, name: 'core/heading', attributes: {}, innerBlocks: [] }, + ] ); + } ); + } ); + + describe( 'getClientIdsOfDescendants', () => { + it( 'should return the ids of any descendants, given an array of clientIds', () => { + const state = { + blocks: { + byClientId: { + 'uuid-2': { clientId: 'uuid-2', name: 'core/image' }, + 'uuid-4': { clientId: 'uuid-4', name: 'core/paragraph' }, + 'uuid-6': { clientId: 'uuid-6', name: 'core/paragraph' }, + 'uuid-8': { clientId: 'uuid-8', name: 'core/block' }, + 'uuid-10': { clientId: 'uuid-10', name: 'core/columns' }, + 'uuid-12': { clientId: 'uuid-12', name: 'core/column' }, + 'uuid-14': { clientId: 'uuid-14', name: 'core/column' }, + 'uuid-16': { clientId: 'uuid-16', name: 'core/quote' }, + 'uuid-18': { clientId: 'uuid-18', name: 'core/block' }, + 'uuid-20': { clientId: 'uuid-20', name: 'core/gallery' }, + 'uuid-22': { clientId: 'uuid-22', name: 'core/block' }, + 'uuid-24': { clientId: 'uuid-24', name: 'core/columns' }, + 'uuid-26': { clientId: 'uuid-26', name: 'core/column' }, + 'uuid-28': { clientId: 'uuid-28', name: 'core/column' }, + 'uuid-30': { clientId: 'uuid-30', name: 'core/paragraph' }, + }, + attributes: { + 'uuid-2': {}, + 'uuid-4': {}, + 'uuid-6': {}, + 'uuid-8': {}, + 'uuid-10': {}, + 'uuid-12': {}, + 'uuid-14': {}, + 'uuid-16': {}, + 'uuid-18': {}, + 'uuid-20': {}, + 'uuid-22': {}, + 'uuid-24': {}, + 'uuid-26': {}, + 'uuid-28': {}, + 'uuid-30': {}, + }, + order: { + '': [ 'uuid-6', 'uuid-8', 'uuid-10', 'uuid-22' ], + 'uuid-2': [ ], + 'uuid-4': [ ], + 'uuid-6': [ ], + 'uuid-8': [ ], + 'uuid-10': [ 'uuid-12', 'uuid-14' ], + 'uuid-12': [ 'uuid-16' ], + 'uuid-14': [ 'uuid-18' ], + 'uuid-16': [ ], + 'uuid-18': [ 'uuid-24' ], + 'uuid-20': [ ], + 'uuid-22': [ ], + 'uuid-24': [ 'uuid-26', 'uuid-28' ], + 'uuid-26': [ ], + 'uuid-28': [ 'uuid-30' ], + }, + parents: { + 'uuid-6': '', + 'uuid-8': '', + 'uuid-10': '', + 'uuid-22': '', + 'uuid-12': 'uuid-10', + 'uuid-14': 'uuid-10', + 'uuid-16': 'uuid-12', + 'uuid-18': 'uuid-14', + 'uuid-24': 'uuid-18', + 'uuid-26': 'uuid-24', + 'uuid-28': 'uuid-24', + 'uuid-30': 'uuid-28', + }, + }, + }; + expect( getClientIdsOfDescendants( state, [ 'uuid-10' ] ) ).toEqual( [ + 'uuid-12', + 'uuid-14', + 'uuid-16', + 'uuid-18', + 'uuid-24', + 'uuid-26', + 'uuid-28', + 'uuid-30', + ] ); + } ); + } ); + + describe( 'getClientIdsWithDescendants', () => { + it( 'should return the ids for top-level blocks and their descendants of any depth (for nested blocks).', () => { + const state = { + blocks: { + byClientId: { + 'uuid-2': { clientId: 'uuid-2', name: 'core/image' }, + 'uuid-4': { clientId: 'uuid-4', name: 'core/paragraph' }, + 'uuid-6': { clientId: 'uuid-6', name: 'core/paragraph' }, + 'uuid-8': { clientId: 'uuid-8', name: 'core/block' }, + 'uuid-10': { clientId: 'uuid-10', name: 'core/columns' }, + 'uuid-12': { clientId: 'uuid-12', name: 'core/column' }, + 'uuid-14': { clientId: 'uuid-14', name: 'core/column' }, + 'uuid-16': { clientId: 'uuid-16', name: 'core/quote' }, + 'uuid-18': { clientId: 'uuid-18', name: 'core/block' }, + 'uuid-20': { clientId: 'uuid-20', name: 'core/gallery' }, + 'uuid-22': { clientId: 'uuid-22', name: 'core/block' }, + 'uuid-24': { clientId: 'uuid-24', name: 'core/columns' }, + 'uuid-26': { clientId: 'uuid-26', name: 'core/column' }, + 'uuid-28': { clientId: 'uuid-28', name: 'core/column' }, + 'uuid-30': { clientId: 'uuid-30', name: 'core/paragraph' }, + }, + attributes: { + 'uuid-2': {}, + 'uuid-4': {}, + 'uuid-6': {}, + 'uuid-8': {}, + 'uuid-10': {}, + 'uuid-12': {}, + 'uuid-14': {}, + 'uuid-16': {}, + 'uuid-18': {}, + 'uuid-20': {}, + 'uuid-22': {}, + 'uuid-24': {}, + 'uuid-26': {}, + 'uuid-28': {}, + 'uuid-30': {}, + }, + order: { + '': [ 'uuid-6', 'uuid-8', 'uuid-10', 'uuid-22' ], + 'uuid-2': [ ], + 'uuid-4': [ ], + 'uuid-6': [ ], + 'uuid-8': [ ], + 'uuid-10': [ 'uuid-12', 'uuid-14' ], + 'uuid-12': [ 'uuid-16' ], + 'uuid-14': [ 'uuid-18' ], + 'uuid-16': [ ], + 'uuid-18': [ 'uuid-24' ], + 'uuid-20': [ ], + 'uuid-22': [ ], + 'uuid-24': [ 'uuid-26', 'uuid-28' ], + 'uuid-26': [ ], + 'uuid-28': [ 'uuid-30' ], + }, + parents: { + 'uuid-6': '', + 'uuid-8': '', + 'uuid-10': '', + 'uuid-22': '', + 'uuid-12': 'uuid-10', + 'uuid-14': 'uuid-10', + 'uuid-16': 'uuid-12', + 'uuid-18': 'uuid-14', + 'uuid-24': 'uuid-18', + 'uuid-26': 'uuid-24', + 'uuid-28': 'uuid-24', + 'uuid-30': 'uuid-28', + }, + }, + }; + expect( getClientIdsWithDescendants( state ) ).toEqual( [ + 'uuid-6', + 'uuid-8', + 'uuid-10', + 'uuid-22', + 'uuid-12', + 'uuid-14', + 'uuid-16', + 'uuid-18', + 'uuid-24', + 'uuid-26', + 'uuid-28', + 'uuid-30', + ] ); + } ); + } ); + + describe( 'getBlockCount', () => { + it( 'should return the number of top-level blocks in the post', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlockCount( state ) ).toBe( 2 ); + } ); + + it( 'should return the number of blocks in a nested context', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/columns' }, + 456: { clientId: 456, name: 'core/paragraph' }, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + 789: {}, + }, + order: { + '': [ 123 ], + 123: [ 456, 789 ], + }, + parents: { + 123: '', + 456: 123, + 789: 123, + }, + }, + }; + + expect( getBlockCount( state, '123' ) ).toBe( 2 ); + } ); + } ); + + describe( 'hasSelectedBlock', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: {}, + end: {}, + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return false if multi-selection', () => { + const state = { + blockSelection: { + start: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + end: { clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189' }, + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return true if singular selection', () => { + const state = { + blockSelection: { + start: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + end: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( true ); + } ); + } ); + + describe( 'getGlobalBlockCount', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/heading' }, + 456: { clientId: 456, name: 'core/paragraph' }, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + 789: {}, + }, + order: { + '': [ 123, 456 ], + }, + parents: { + 123: '', + 456: '', + }, + }, + }; + + it( 'should return the global number of blocks in the post', () => { + expect( getGlobalBlockCount( state ) ).toBe( 2 ); + } ); + + it( 'should return the global number of blocks in the post of a given type', () => { + expect( getGlobalBlockCount( state, 'core/paragraph' ) ).toBe( 1 ); + } ); + + it( 'should return 0 if no blocks exist', () => { + const emptyState = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + }, + }; + expect( getGlobalBlockCount( emptyState ) ).toBe( 0 ); + expect( getGlobalBlockCount( emptyState, 'core/heading' ) ).toBe( 0 ); + } ); + } ); + + describe( 'getSelectedBlockClientId', () => { + it( 'should return null if no block is selected', () => { + const state = { + blockSelection: { start: {}, end: {} }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + blockSelection: { start: { clientId: 23 }, end: { clientId: 123 } }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return the selected block ClientId', () => { + const state = { + blocks: { + byClientId: { + 23: { + name: 'fake block', + }, + }, + }, + blockSelection: { start: { clientId: 23 }, end: { clientId: 23 } }, + }; + + expect( getSelectedBlockClientId( state ) ).toEqual( 23 ); + } ); + } ); + + describe( 'getSelectedBlock', () => { + it( 'should return null if no block is selected', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + parents: { + 23: '', + 123: '', + }, + }, + blockSelection: { start: {}, end: {} }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + parents: { + 123: '', + 23: '', + }, + }, + blockSelection: { start: { clientId: 23 }, end: { clientId: 123 } }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return the selected block', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + parents: { + 123: '', + 23: '', + }, + cache: { + 23: {}, + }, + }, + blockSelection: { start: { clientId: 23 }, end: { clientId: 23 } }, + }; + + expect( getSelectedBlock( state ) ).toEqual( { + clientId: 23, + name: 'core/heading', + attributes: {}, + innerBlocks: [], + } ); + } ); + } ); + + describe( 'getBlockRootClientId', () => { + it( 'should return null if the block does not exist', () => { + const state = { + blocks: { + order: {}, + parents: {}, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBeNull(); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 123: '', + 23: '', + 456: 123, + 56: 123, + }, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBe( 123 ); + } ); + } ); + + describe( 'getBlockHierarchyRootClientId', () => { + it( 'should return the given block if the block has no parents', () => { + const state = { + blocks: { + order: {}, + parents: {}, + }, + }; + + expect( getBlockHierarchyRootClientId( state, '56' ) ).toBe( '56' ); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 'a', 'b' ], + a: [ 'c', 'd' ], + }, + parents: { + a: '', + b: '', + c: 'a', + d: 'a', + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 'c' ) ).toBe( 'a' ); + } ); + + it( 'should return the top level root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 'a', 'b' ], + a: [ 'c', 'd' ], + d: [ 'e' ], + }, + parents: { + a: '', + b: '', + c: 'a', + d: 'a', + e: 'd', + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 'e' ) ).toBe( 'a' ); + } ); + } ); + + describe( 'getSelectedBlockClientIds', () => { + it( 'should return empty if there is no selection', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 123: '', + 23: '', + }, + }, + blockSelection: { start: {}, end: {} }, + }; + + expect( getSelectedBlockClientIds( state ) ).toEqual( [] ); + } ); + + it( 'should return the selected block clientId if there is a selection', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 2 } }, + }; + + expect( getSelectedBlockClientIds( state ) ).toEqual( [ 2 ] ); + } ); + + it( 'should return selected block clientIds if there is multi selection', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + expect( getSelectedBlockClientIds( state ) ).toEqual( [ 4, 3, 2 ] ); + } ); + + it( 'should return selected block clientIds if there is multi selection (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + 4: [ 9, 8, 7, 6 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + 6: 4, + 7: 4, + 8: 4, + 9: 4, + }, + }, + blockSelection: { start: { clientId: 7 }, end: { clientId: 9 } }, + }; + + expect( getSelectedBlockClientIds( state ) ).toEqual( [ 9, 8, 7 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlockClientIds', () => { + it( 'should return empty if there is no multi selection', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + blockSelection: { start: {}, end: {} }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [] ); + } ); + + it( 'should return selected block clientIds if there is multi selection', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [ 4, 3, 2 ] ); + } ); + + it( 'should return selected block clientIds if there is multi selection (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + 4: [ 9, 8, 7, 6 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + 6: 4, + 7: 4, + 8: 4, + 9: 4, + }, + }, + blockSelection: { start: { clientId: 7 }, end: { clientId: 9 } }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [ 9, 8, 7 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlocks', () => { + it( 'should return the same reference on subsequent invocations of empty selection', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + }, + blockSelection: { start: {}, end: {} }, + }; + + expect( + getMultiSelectedBlocks( state ) + ).toBe( getMultiSelectedBlocks( state ) ); + } ); + } ); + + describe( 'getMultiSelectedBlocksStartClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: {}, end: {} }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection start', () => { + const state = { + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBe( 2 ); + } ); + } ); + + describe( 'getMultiSelectedBlocksEndClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: {}, end: {} }, + }; + + expect( getMultiSelectedBlocksEndClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection end', () => { + const state = { + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + expect( getMultiSelectedBlocksEndClientId( state ) ).toBe( 4 ); + } ); + } ); + + describe( 'getBlockOrder', () => { + it( 'should return the ordered block ClientIds of top-level blocks by default', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getBlockOrder( state ) ).toEqual( [ 123, 23 ] ); + } ); + + it( 'should return the ordered block ClientIds at a specified rootClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456 ], + }, + parents: { + 23: '', + 123: '', + 456: 123, + }, + }, + }; + + expect( getBlockOrder( state, '123' ) ).toEqual( [ 456 ] ); + } ); + } ); + + describe( 'getBlockIndex', () => { + it( 'should return the block order', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getBlockIndex( state, 23 ) ).toBe( 1 ); + } ); + + it( 'should return the block order (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 23: '', + 123: '', + 56: 123, + 456: 123, + }, + }, + }; + + expect( getBlockIndex( state, 56, '123' ) ).toBe( 1 ); + } ); + } ); + + describe( 'getPreviousBlockClientId', () => { + it( 'should return the previous block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getPreviousBlockClientId( state, 23 ) ).toEqual( 123 ); + } ); + + it( 'should return the previous block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 23: '', + 123: '', + 456: 123, + 56: 123, + }, + }, + }; + + expect( getPreviousBlockClientId( state, 56, '123' ) ).toEqual( 456 ); + } ); + + it( 'should return null for the first block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getPreviousBlockClientId( state, 123 ) ).toBeNull(); + } ); + + it( 'should return null for the first block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 23: '', + 123: '', + 456: 123, + 56: 123, + }, + }, + }; + + expect( getPreviousBlockClientId( state, 456, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'getNextBlockClientId', () => { + it( 'should return the following block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getNextBlockClientId( state, 123 ) ).toEqual( 23 ); + } ); + + it( 'should return the following block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 23: '', + 123: '', + 456: 123, + 56: 123, + }, + }, + }; + + expect( getNextBlockClientId( state, 456, '123' ) ).toEqual( 56 ); + } ); + + it( 'should return null for the last block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + parents: { + 23: '', + 123: '', + }, + }, + }; + + expect( getNextBlockClientId( state, 23 ) ).toBeNull(); + } ); + + it( 'should return null for the last block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + parents: { + 23: '', + 123: '', + 456: 123, + 56: 123, + }, + }, + }; + + expect( getNextBlockClientId( state, 56, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'isBlockSelected', () => { + it( 'should return true if the block is selected', () => { + const state = { + blockSelection: { start: { clientId: 123 }, end: { clientId: 123 } }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false if a multi-selection range exists', () => { + const state = { + blockSelection: { start: { clientId: 123 }, end: { clientId: 124 } }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: {}, end: {} }, + }; + + expect( isBlockSelected( state, 23 ) ).toBe( false ); + } ); + } ); + + describe( 'hasSelectedInnerBlock', () => { + it( 'should return false if the selected block is a child of the given ClientId', () => { + const state = { + blockSelection: { start: { clientId: 5 }, end: { clientId: 5 } }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + parents: { + 1: 4, + 2: 4, + 3: 4, + }, + }, + }; + + expect( hasSelectedInnerBlock( state, 4 ) ).toBe( false ); + } ); + + it( 'should return true if the selected block is a child of the given ClientId', () => { + const state = { + blockSelection: { start: { clientId: 3 }, end: { clientId: 3 } }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + parents: { + 1: 4, + 2: 4, + 3: 4, + }, + }, + }; + + expect( hasSelectedInnerBlock( state, 4 ) ).toBe( true ); + } ); + + it( 'should return true if a multi selection exists that contains children of the block with the given ClientId', () => { + const state = { + blocks: { + order: { + 6: [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: 6, + 2: 6, + 3: 6, + 4: 6, + 5: 6, + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + expect( hasSelectedInnerBlock( state, 6 ) ).toBe( true ); + } ); + + it( 'should return false if a multi selection exists bot does not contains children of the block with the given ClientId', () => { + const state = { + blocks: { + order: { + 3: [ 2, 1 ], + 6: [ 5, 4 ], + }, + parents: { + 1: 3, + 2: 3, + 4: 6, + 5: 6, + }, + }, + blockSelection: { start: { clientId: 5 }, end: { clientId: 4 } }, + }; + expect( hasSelectedInnerBlock( state, 3 ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockWithinSelection', () => { + it( 'should return true if the block is selected but not the last', () => { + const state = { + blockSelection: { start: { clientId: 5 }, end: { clientId: 3 } }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is the last selected', () => { + const state = { + blockSelection: { start: { clientId: 5 }, end: { clientId: 3 } }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + }; + + expect( isBlockWithinSelection( state, 3 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: { clientId: 5 }, end: { clientId: 3 } }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + }; + + expect( isBlockWithinSelection( state, 2 ) ).toBe( false ); + } ); + + it( 'should return false if there is no selection', () => { + const state = { + blockSelection: { start: {}, end: {} }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( false ); + } ); + } ); + + describe( 'hasMultiSelection', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: {}, + end: {}, + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return false if singular selection', () => { + const state = { + blockSelection: { + start: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + end: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return true if multi-selection', () => { + const state = { + blockSelection: { + start: { clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' }, + end: { clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189' }, + }, + }; + + expect( hasMultiSelection( state ) ).toBe( true ); + } ); + } ); + + describe( 'isBlockMultiSelected', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + it( 'should return true if the block is multi selected', () => { + expect( isBlockMultiSelected( state, 3 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not multi selected', () => { + expect( isBlockMultiSelected( state, 5 ) ).toBe( false ); + } ); + } ); + + describe( 'isFirstMultiSelectedBlock', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + parents: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: '', + }, + }, + blockSelection: { start: { clientId: 2 }, end: { clientId: 4 } }, + }; + + it( 'should return true if the block is first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 3 ) ).toBe( false ); + } ); + } ); + + describe( 'getBlockMode', () => { + it( 'should return "visual" if unset', () => { + const state = { + blocksMode: {}, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'visual' ); + } ); + + it( 'should return the block mode', () => { + const state = { + blocksMode: { + 123: 'html', + }, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'html' ); + } ); + } ); + + describe( 'isTyping', () => { + it( 'should return the isTyping flag if the block is selected', () => { + const state = { + isTyping: true, + }; + + expect( isTyping( state ) ).toBe( true ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + isTyping: false, + }; + + expect( isTyping( state ) ).toBe( false ); + } ); + } ); + + describe( 'isCaretWithinFormattedText', () => { + it( 'returns true if the isCaretWithinFormattedText state is also true', () => { + const state = { + isCaretWithinFormattedText: true, + }; + + expect( isCaretWithinFormattedText( state ) ).toBe( true ); + } ); + + it( 'returns false if the isCaretWithinFormattedText state is also false', () => { + const state = { + isCaretWithinFormattedText: false, + }; + + expect( isCaretWithinFormattedText( state ) ).toBe( false ); + } ); + } ); + + describe( 'isSelectionEnabled', () => { + it( 'should return true if selection is enable', () => { + const state = { + blockSelection: { + isEnabled: true, + }, + }; + + expect( isSelectionEnabled( state ) ).toBe( true ); + } ); + + it( 'should return false if selection is disabled', () => { + const state = { + blockSelection: { + isEnabled: false, + }, + }; + + expect( isSelectionEnabled( state ) ).toBe( false ); + } ); + } ); + + describe( 'getBlockInsertionPoint', () => { + it( 'should return the explicitly assigned insertion point', () => { + const state = { + blockSelection: { + start: { clientId: 'clientId2' }, + end: { clientId: 'clientId2' }, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + parents: { + clientId1: '', + clientId2: 'clientId1', + }, + }, + insertionPoint: { + rootClientId: undefined, + index: 0, + }, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 0, + } ); + } ); + + it( 'should return an object for the selected block', () => { + const state = { + blockSelection: { + start: { clientId: 'clientId1' }, + end: { clientId: 'clientId1' }, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + }, + attributes: { + clientId1: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [], + }, + parents: { + clientId1: '', + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 1, + } ); + } ); + + it( 'should return an object for the nested selected block', () => { + const state = { + blockSelection: { + start: { clientId: 'clientId2' }, + end: { clientId: 'clientId2' }, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + parents: { + clientId1: '', + clientId2: 'clientId1', + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: 'clientId1', + index: 1, + } ); + } ); + + it( 'should return an object for the last multi selected clientId', () => { + const state = { + blockSelection: { + start: { clientId: 'clientId1' }, + end: { clientId: 'clientId2' }, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + clientId1: [], + clientId2: [], + }, + parents: { + clientId1: '', + clientId2: '', + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 2, + } ); + } ); + + it( 'should return an object for the last block if no selection', () => { + const state = { + blockSelection: { + start: {}, + end: {}, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + clientId1: [], + clientId2: [], + }, + parents: { + clientId1: '', + clientId2: '', + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 2, + } ); + } ); + } ); + + describe( 'isBlockInsertionPointVisible', () => { + it( 'should return false if no assigned insertion point', () => { + const state = { + insertionPoint: null, + }; + + expect( isBlockInsertionPointVisible( state ) ).toBe( false ); + } ); + + it( 'should return true if assigned insertion point', () => { + const state = { + insertionPoint: { + rootClientId: undefined, + index: 5, + }, + }; + + expect( isBlockInsertionPointVisible( state ) ).toBe( true ); + } ); + } ); + + describe( 'canInsertBlockType', () => { + it( 'should deny blocks that are not registered', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/invalid' ) ).toBe( false ); + } ); + + it( 'should deny blocks that are not allowed by the editor', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + allowedBlockTypes: [], + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( false ); + } ); + + it( 'should allow blocks that are allowed by the editor', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + allowedBlockTypes: [ 'core/test-block-a' ], + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( true ); + } ); + + it( 'should deny blocks when the editor has a template lock', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + templateLock: 'all', + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( false ); + } ); + + it( 'should deny blocks that restrict parent from being inserted into the root', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c' ) ).toBe( false ); + } ); + + it( 'should deny blocks that restrict parent from being inserted into a restricted parent', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( false ); + } ); + + it( 'should allow blocks that restrict parent to be inserted into an allowed parent', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( true ); + } ); + + it( 'should deny restricted blocks from being inserted into a block that restricts allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [ 'core/test-block-c' ], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) ).toBe( false ); + } ); + + it( 'should allow allowed blocks to be inserted into a block that restricts allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [ 'core/test-block-b' ], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) ).toBe( true ); + } ); + + it( 'should prioritise parent over allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( true ); + } ); + } ); + + describe( 'getInserterItems', () => { + it( 'should properly list block type and reusable block items', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + cache: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { + id: 1, + isTemporary: false, + clientId: 'block1', + title: 'Reusable Block 1', + content: '<!-- /wp:test-block-a -->', + }, + ], + }, + // Intentionally include a test case which considers + // `insertUsage` as not present within preferences. + // + // See: https://github.com/WordPress/gutenberg/issues/14580 + preferences: {}, + blockListSettings: {}, + }; + const items = getInserterItems( state ); + const testBlockAItem = items.find( ( item ) => item.id === 'core/test-block-a' ); + expect( testBlockAItem ).toEqual( { + id: 'core/test-block-a', + name: 'core/test-block-a', + initialAttributes: {}, + title: 'Test Block A', + icon: { + src: 'test', + }, + category: 'formatting', + keywords: [ 'testing' ], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + const reusableBlockItem = items.find( ( item ) => item.id === 'core/block/1' ); + expect( reusableBlockItem ).toEqual( { + id: 'core/block/1', + name: 'core/block', + initialAttributes: { ref: 1 }, + title: 'Reusable Block 1', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + + it( 'should order items by descending utility and frecency', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + cache: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { + id: 1, + isTemporary: false, + clientId: 'block1', + title: 'Reusable Block 1', + content: '<!-- /wp:test-block-a -->', + }, + { + id: 2, + isTemporary: false, + clientId: 'block2', + title: 'Reusable Block 2', + content: '<!-- /wp:test-block-b -->', + }, + ], + }, + preferences: { + insertUsage: { + 'core/block/1': { count: 10, time: 1000 }, + 'core/block/2': { count: 20, time: 1000 }, + }, + }, + blockListSettings: {}, + }; + const itemIDs = getInserterItems( state ).map( ( item ) => item.id ); + expect( itemIDs ).toEqual( [ + 'core/block/2', + 'core/block/1', + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + ] ); + } ); + + it( 'should correctly cache the return values', () => { + const state = { + blocks: { + byClientId: { + block3: { name: 'core/test-block-a' }, + block4: { name: 'core/test-block-a' }, + }, + attributes: { + block3: {}, + block4: {}, + }, + order: { + '': [ 'block3', 'block4' ], + }, + parents: { + block3: '', + block4: '', + }, + cache: { + block3: {}, + block4: {}, + }, + }, + settings: { + __experimentalReusableBlocks: [ + { + id: 1, + isTemporary: false, + clientId: 'block1', + title: 'Reusable Block 1', + content: '<!-- /wp:test-block-a -->', + }, + { + id: 2, + isTemporary: false, + clientId: 'block2', + title: 'Reusable Block 2', + content: '<!-- /wp:test-block-b -->', + }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + + const stateSecondBlockRestricted = { + ...state, + blockListSettings: { + block4: { + allowedBlocks: [ 'core/test-block-b' ], + }, + }, + }; + + const firstBlockFirstCall = getInserterItems( state, 'block3' ); + const firstBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block3' ); + expect( firstBlockFirstCall ).toBe( firstBlockSecondCall ); + expect( firstBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + 'core/block/1', + 'core/block/2', + ] ); + + const secondBlockFirstCall = getInserterItems( state, 'block4' ); + const secondBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block4' ); + expect( secondBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + 'core/block/1', + 'core/block/2', + ] ); + expect( secondBlockSecondCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + ] ); + } ); + + it( 'should set isDisabled when a block with `multiple: false` has been used', () => { + const state = { + blocks: { + byClientId: { + block1: { clientId: 'block1', name: 'core/test-block-b' }, + }, + attributes: { + block1: { attribute: {} }, + }, + order: { + '': [ 'block1' ], + }, + cache: { + block1: {}, + }, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const testBlockBItem = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( testBlockBItem.isDisabled ).toBe( true ); + } ); + + it( 'should give common blocks a low utility', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + cache: {}, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const testBlockBItem = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( testBlockBItem.utility ).toBe( INSERTER_UTILITY_LOW ); + } ); + + it( 'should give used blocks a medium utility and set a frecency', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + parents: {}, + cache: {}, + }, + preferences: { + insertUsage: { + 'core/test-block-b': { count: 10, time: 1000 }, + }, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const reusableBlock2Item = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( reusableBlock2Item.utility ).toBe( INSERTER_UTILITY_MEDIUM ); + expect( reusableBlock2Item.frecency ).toBe( 2.5 ); + } ); + + it( 'should give contextual blocks a high utility', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: { attribute: {} }, + }, + order: { + '': [ 'block1' ], + }, + parents: { + block1: '', + }, + cache: { + block1: {}, + }, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state, 'block1' ); + const testBlockCItem = items.find( ( item ) => item.id === 'core/test-block-c' ); + expect( testBlockCItem.utility ).toBe( INSERTER_UTILITY_HIGH ); + } ); + } ); + + describe( 'isValidTemplate', () => { + it( 'should return true if template is valid', () => { + const state = { + template: { isValid: true }, + }; + + expect( isValidTemplate( state ) ).toBe( true ); + } ); + + it( 'should return false if template is not valid', () => { + const state = { + template: { isValid: false }, + }; + + expect( isValidTemplate( state ) ).toBe( false ); + } ); + } ); + + describe( 'getTemplate', () => { + it( 'should return the template object', () => { + const template = []; + const state = { + settings: { template }, + }; + + expect( getTemplate( state ) ).toBe( template ); + } ); + } ); + + describe( 'getTemplateLock', () => { + it( 'should return the general template lock if no clientId was set', () => { + const state = { + settings: { templateLock: 'all' }, + }; + + expect( getTemplateLock( state ) ).toBe( 'all' ); + } ); + + it( 'should return null if the specified clientId was not found ', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + templateLock: 'insert', + }, + }, + }; + + expect( getTemplateLock( state, 'ribs' ) ).toBe( null ); + } ); + + it( 'should return null if template lock was not set on the specified block', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + test: 'tes1t', + }, + }, + }; + + expect( getTemplateLock( state, 'ribs' ) ).toBe( null ); + } ); + + it( 'should return the template lock for the specified clientId', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + templateLock: 'insert', + }, + }, + }; + + expect( getTemplateLock( state, 'chicken' ) ).toBe( 'insert' ); + } ); + } ); + + describe( 'getBlockListSettings', () => { + it( 'should return the settings of a block', () => { + const state = { + blockListSettings: { + chicken: { + setting1: false, + }, + ribs: { + setting2: true, + }, + }, + }; + + expect( getBlockListSettings( state, 'chicken' ) ).toEqual( { + setting1: false, + } ); + } ); + + it( 'should return undefined if settings for the block don’t exist', () => { + const state = { + blockListSettings: {}, + }; + + expect( getBlockListSettings( state, 'chicken' ) ).toBe( undefined ); + } ); + } ); + + describe( '__experimentalGetLastBlockAttributeChanges', () => { + it( 'returns the last block attributes change', () => { + const state = { + lastBlockAttributesChange: { + block1: { fruit: 'bananas' }, + }, + }; + + const result = __experimentalGetLastBlockAttributeChanges( state ); + + expect( result ).toEqual( { + block1: { fruit: 'bananas' }, + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/style.scss b/gb-src/packages/block-editor/src/style.scss new file mode 100644 index 0000000000000..95d7a0805873c --- /dev/null +++ b/gb-src/packages/block-editor/src/style.scss @@ -0,0 +1,35 @@ +@import "./components/block-drop-zone/style.scss"; +@import "./components/block-icon/style.scss"; +@import "./components/block-inspector/style.scss"; +@import "./components/block-list/style.scss"; +@import "./components/block-list-appender/style.scss"; +@import "./components/block-card/style.scss"; +@import "./components/block-compare/style.scss"; +@import "./components/block-mover/style.scss"; +@import "./components/block-navigation/style.scss"; +@import "./components/block-preview/style.scss"; +@import "./components/block-settings-menu/style.scss"; +@import "./components/block-styles/style.scss"; +@import "./components/block-switcher/style.scss"; +@import "./components/block-toolbar/style.scss"; +@import "./components/block-types-list/style.scss"; +@import "./components/button-block-appender/style.scss"; +@import "./components/color-palette/control.scss"; +@import "./components/contrast-checker/style.scss"; +@import "./components/default-block-appender/style.scss"; +@import "./components/inner-blocks/style.scss"; +@import "./components/inserter-with-shortcuts/style.scss"; +@import "./components/inserter/style.scss"; +@import "./components/inserter-list-item/style.scss"; +@import "./components/media-placeholder/style.scss"; +@import "./components/multi-selection-inspector/style.scss"; +@import "./components/panel-color-settings/style.scss"; +@import "./components/plain-text/style.scss"; +@import "./components/rich-text/format-toolbar/style.scss"; +@import "./components/rich-text/style.scss"; +@import "./components/skip-to-selected-block/style.scss"; +@import "./components/url-input/style.scss"; +@import "./components/url-popover/style.scss"; +@import "./components/warning/style.scss"; +@import "./components/writing-flow/style.scss"; +@import "./hooks/anchor.scss"; diff --git a/gb-src/packages/block-editor/src/utils/dom.js b/gb-src/packages/block-editor/src/utils/dom.js new file mode 100644 index 0000000000000..fd732b40c5318 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/dom.js @@ -0,0 +1,88 @@ +/** + * Given a block client ID, returns the corresponding DOM node for the block, + * if exists. As much as possible, this helper should be avoided, and used only + * in cases where isolated behaviors need remote access to a block node. + * + * @param {string} clientId Block client ID. + * @param {Element} scope an optional DOM Element to which the selector should be scoped + * + * @return {Element} Block DOM node. + */ +export function getBlockDOMNode( clientId, scope = document ) { + return scope.querySelector( '[data-block="' + clientId + '"]' ); +} + +export function getBlockPreviewContainerDOMNode( clientId, scope ) { + const domNode = getBlockDOMNode( clientId, scope ); + + if ( ! domNode ) { + return; + } + + return domNode.firstChild || domNode; +} + +/** + * Given a block client ID, returns the corresponding DOM node for the block + * focusable wrapper, if exists. As much as possible, this helper should be + * avoided, and used only in cases where isolated behaviors need remote access + * to a block node. + * + * @param {string} clientId Block client ID. + * + * @return {Element} Block DOM node. + */ +export function getBlockFocusableWrapper( clientId ) { + return getBlockDOMNode( clientId ).closest( '.block-editor-block-list__block' ); +} + +/** + * Returns true if the given HTMLElement is a block focus stop. Blocks without + * their own text fields rely on the focus stop to be keyboard navigable. + * + * @param {HTMLElement} element Element to test. + * + * @return {boolean} Whether element is a block focus stop. + */ +export function isBlockFocusStop( element ) { + return element.classList.contains( 'block-editor-block-list__block' ); +} + +/** + * Returns true if two elements are contained within the same block. + * + * @param {HTMLElement} a First element. + * @param {HTMLElement} b Second element. + * + * @return {boolean} Whether elements are in the same block. + */ +export function isInSameBlock( a, b ) { + return a.closest( '[data-block]' ) === b.closest( '[data-block]' ); +} + +/** + * Returns true if an elements is considered part of the block and not its children. + * + * @param {HTMLElement} blockElement Block container element. + * @param {HTMLElement} element Element. + * + * @return {boolean} Whether element is in the block Element but not its children. + */ +export function isInsideRootBlock( blockElement, element ) { + const innerBlocksContainer = blockElement.querySelector( '.block-editor-block-list__layout' ); + return blockElement.contains( element ) && ( + ! innerBlocksContainer || ! innerBlocksContainer.contains( element ) + ); +} + +/** + * Returns true if the given HTMLElement contains inner blocks (an InnerBlocks + * element). + * + * @param {HTMLElement} element Element to test. + * + * @return {boolean} Whether element contains inner blocks. + */ +export function hasInnerBlocksContext( element ) { + return !! element.querySelector( '.block-editor-block-list__layout' ); +} diff --git a/gb-src/packages/block-editor/src/utils/index.js b/gb-src/packages/block-editor/src/utils/index.js new file mode 100644 index 0000000000000..95241bd4fa767 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/index.js @@ -0,0 +1 @@ +export { default as transformStyles } from './transform-styles'; diff --git a/gb-src/packages/block-editor/src/utils/test/dom.js b/gb-src/packages/block-editor/src/utils/test/dom.js new file mode 100644 index 0000000000000..45533fa436a19 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/test/dom.js @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import { hasInnerBlocksContext } from '../dom'; + +describe( 'hasInnerBlocksContext()', () => { + it( 'should return false for a block node which has no inner blocks', () => { + const wrapper = document.createElement( 'div' ); + wrapper.innerHTML = ( + '<div class="editor-block-list__block block-editor-block-list__block" data-type="core/paragraph" tabindex="0">' + + ' <div class="editor-block-list__block-edit block-editor-block-list__block-edit" aria-label="Block: Paragraph">' + + ' <p contenteditable="true">This is a test.</p>' + + ' </div>' + + '</div>' + ); + + const blockNode = wrapper.firstChild; + expect( hasInnerBlocksContext( blockNode ) ).toBe( false ); + } ); + + it( 'should return true for a block node which contains inner blocks', () => { + const wrapper = document.createElement( 'div' ); + wrapper.innerHTML = ( + '<div class="editor-block-list__block block-editor-block-list__block" data-type="core/columns" tabindex="0">' + + ' <div class="editor-block-list__block-edit block-editor-block-list__block-edit" aria-label="Block: Columns (beta)">' + + ' <div class="wp-block-columns has-2-columns">' + + ' <div class="editor-block-list__layout block-editor-block-list__layout"></div>' + + ' <div class="editor-block-list__layout block-editor-block-list__layout"></div>' + + ' </div>' + + ' </div>' + + '</div>' + ); + + const blockNode = wrapper.firstChild; + expect( hasInnerBlocksContext( blockNode ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/index.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/index.js new file mode 100644 index 0000000000000..b4dc1de499f47 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/index.js @@ -0,0 +1,5 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +export { default as parse } from './parse'; +export { default as stringify } from './stringify'; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/parse.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/parse.js new file mode 100644 index 0000000000000..11e8d22a37847 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/parse.js @@ -0,0 +1,688 @@ +/* eslint-disable @wordpress/no-unused-vars-before-return */ + +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +// http://www.w3.org/TR/CSS21/grammar.htm +// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027 +const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g; + +export default function( css, options ) { + options = options || {}; + + /** + * Positional. + */ + + let lineno = 1; + let column = 1; + + /** + * Update lineno and column based on `str`. + */ + + function updatePosition( str ) { + const lines = str.match( /\n/g ); + if ( lines ) { + lineno += lines.length; + } + const i = str.lastIndexOf( '\n' ); + // eslint-disable-next-line no-bitwise + column = ~i ? str.length - i : column + str.length; + } + + /** + * Mark position and patch `node.position`. + */ + + function position() { + const start = { line: lineno, column }; + return function( node ) { + node.position = new Position( start ); + whitespace(); + return node; + }; + } + + /** + * Store position information for a node + */ + + function Position( start ) { + this.start = start; + this.end = { line: lineno, column }; + this.source = options.source; + } + + /** + * Non-enumerable source string + */ + + Position.prototype.content = css; + + /** + * Error `msg`. + */ + + const errorsList = []; + + function error( msg ) { + const err = new Error( options.source + ':' + lineno + ':' + column + ': ' + msg ); + err.reason = msg; + err.filename = options.source; + err.line = lineno; + err.column = column; + err.source = css; + + if ( options.silent ) { + errorsList.push( err ); + } else { + throw err; + } + } + + /** + * Parse stylesheet. + */ + + function stylesheet() { + const rulesList = rules(); + + return { + type: 'stylesheet', + stylesheet: { + source: options.source, + rules: rulesList, + parsingErrors: errorsList, + }, + }; + } + + /** + * Opening brace. + */ + + function open() { + return match( /^{\s*/ ); + } + + /** + * Closing brace. + */ + + function close() { + return match( /^}/ ); + } + + /** + * Parse ruleset. + */ + + function rules() { + let node; + const accumulator = []; + whitespace(); + comments( accumulator ); + while ( css.length && css.charAt( 0 ) !== '}' && ( node = atrule() || rule() ) ) { + if ( node !== false ) { + accumulator.push( node ); + comments( accumulator ); + } + } + return accumulator; + } + + /** + * Match `re` and return captures. + */ + + function match( re ) { + const m = re.exec( css ); + if ( ! m ) { + return; + } + const str = m[ 0 ]; + updatePosition( str ); + css = css.slice( str.length ); + return m; + } + + /** + * Parse whitespace. + */ + + function whitespace() { + match( /^\s*/ ); + } + + /** + * Parse comments; + */ + + function comments( accumulator ) { + let c; + accumulator = accumulator || []; + // eslint-disable-next-line no-cond-assign + while ( c = comment() ) { + if ( c !== false ) { + accumulator.push( c ); + } + } + return accumulator; + } + + /** + * Parse comment. + */ + + function comment() { + const pos = position(); + if ( '/' !== css.charAt( 0 ) || '*' !== css.charAt( 1 ) ) { + return; + } + + let i = 2; + while ( '' !== css.charAt( i ) && ( '*' !== css.charAt( i ) || '/' !== css.charAt( i + 1 ) ) ) { + ++i; + } + i += 2; + + if ( '' === css.charAt( i - 1 ) ) { + return error( 'End of comment missing' ); + } + + const str = css.slice( 2, i - 2 ); + column += 2; + updatePosition( str ); + css = css.slice( i ); + column += 2; + + return pos( { + type: 'comment', + comment: str, + } ); + } + + /** + * Parse selector. + */ + + function selector() { + const m = match( /^([^{]+)/ ); + if ( ! m ) { + return; + } + // FIXME: Remove all comments from selectors http://ostermiller.org/findcomment.html + return trim( m[ 0 ] ) + .replace( /\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '' ) + .replace( /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, function( matched ) { + return matched.replace( /,/g, '\u200C' ); + } ) + .split( /\s*(?![^(]*\)),\s*/ ) + .map( function( s ) { + return s.replace( /\u200C/g, ',' ); + } ); + } + + /** + * Parse declaration. + */ + + function declaration() { + const pos = position(); + + // prop + let prop = match( /^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/ ); + if ( ! prop ) { + return; + } + prop = trim( prop[ 0 ] ); + + // : + if ( ! match( /^:\s*/ ) ) { + return error( "property missing ':'" ); + } + + // val + const val = match( /^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/ ); + + const ret = pos( { + type: 'declaration', + property: prop.replace( commentre, '' ), + value: val ? trim( val[ 0 ] ).replace( commentre, '' ) : '', + } ); + + // ; + match( /^[;\s]*/ ); + + return ret; + } + + /** + * Parse declarations. + */ + + function declarations() { + const decls = []; + + if ( ! open() ) { + return error( "missing '{'" ); + } + comments( decls ); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + if ( decl !== false ) { + decls.push( decl ); + comments( decls ); + } + } + + if ( ! close() ) { + return error( "missing '}'" ); + } + return decls; + } + + /** + * Parse keyframe. + */ + + function keyframe() { + let m; + const vals = []; + const pos = position(); + + // eslint-disable-next-line no-cond-assign + while ( m = match( /^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/ ) ) { + vals.push( m[ 1 ] ); + match( /^,\s*/ ); + } + + if ( ! vals.length ) { + return; + } + + return pos( { + type: 'keyframe', + values: vals, + declarations: declarations(), + } ); + } + + /** + * Parse keyframes. + */ + + function atkeyframes() { + const pos = position(); + let m = match( /^@([-\w]+)?keyframes\s*/ ); + + if ( ! m ) { + return; + } + const vendor = m[ 1 ]; + + // identifier + m = match( /^([-\w]+)\s*/ ); + if ( ! m ) { + return error( '@keyframes missing name' ); + } + const name = m[ 1 ]; + + if ( ! open() ) { + return error( "@keyframes missing '{'" ); + } + + let frame; + let frames = comments(); + // eslint-disable-next-line no-cond-assign + while ( frame = keyframe() ) { + frames.push( frame ); + frames = frames.concat( comments() ); + } + + if ( ! close() ) { + return error( "@keyframes missing '}'" ); + } + + return pos( { + type: 'keyframes', + name, + vendor, + keyframes: frames, + } ); + } + + /** + * Parse supports. + */ + + function atsupports() { + const pos = position(); + const m = match( /^@supports *([^{]+)/ ); + + if ( ! m ) { + return; + } + const supports = trim( m[ 1 ] ); + + if ( ! open() ) { + return error( "@supports missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@supports missing '}'" ); + } + + return pos( { + type: 'supports', + supports, + rules: style, + } ); + } + + /** + * Parse host. + */ + + function athost() { + const pos = position(); + const m = match( /^@host\s*/ ); + + if ( ! m ) { + return; + } + + if ( ! open() ) { + return error( "@host missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@host missing '}'" ); + } + + return pos( { + type: 'host', + rules: style, + } ); + } + + /** + * Parse media. + */ + + function atmedia() { + const pos = position(); + const m = match( /^@media *([^{]+)/ ); + + if ( ! m ) { + return; + } + const media = trim( m[ 1 ] ); + + if ( ! open() ) { + return error( "@media missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@media missing '}'" ); + } + + return pos( { + type: 'media', + media, + rules: style, + } ); + } + + /** + * Parse custom-media. + */ + + function atcustommedia() { + const pos = position(); + const m = match( /^@custom-media\s+(--[^\s]+)\s*([^{;]+);/ ); + if ( ! m ) { + return; + } + + return pos( { + type: 'custom-media', + name: trim( m[ 1 ] ), + media: trim( m[ 2 ] ), + } ); + } + + /** + * Parse paged media. + */ + + function atpage() { + const pos = position(); + const m = match( /^@page */ ); + if ( ! m ) { + return; + } + + const sel = selector() || []; + + if ( ! open() ) { + return error( "@page missing '{'" ); + } + let decls = comments(); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + decls.push( decl ); + decls = decls.concat( comments() ); + } + + if ( ! close() ) { + return error( "@page missing '}'" ); + } + + return pos( { + type: 'page', + selectors: sel, + declarations: decls, + } ); + } + + /** + * Parse document. + */ + + function atdocument() { + const pos = position(); + const m = match( /^@([-\w]+)?document *([^{]+)/ ); + if ( ! m ) { + return; + } + + const vendor = trim( m[ 1 ] ); + const doc = trim( m[ 2 ] ); + + if ( ! open() ) { + return error( "@document missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@document missing '}'" ); + } + + return pos( { + type: 'document', + document: doc, + vendor, + rules: style, + } ); + } + + /** + * Parse font-face. + */ + + function atfontface() { + const pos = position(); + const m = match( /^@font-face\s*/ ); + if ( ! m ) { + return; + } + + if ( ! open() ) { + return error( "@font-face missing '{'" ); + } + let decls = comments(); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + decls.push( decl ); + decls = decls.concat( comments() ); + } + + if ( ! close() ) { + return error( "@font-face missing '}'" ); + } + + return pos( { + type: 'font-face', + declarations: decls, + } ); + } + + /** + * Parse import + */ + + const atimport = _compileAtrule( 'import' ); + + /** + * Parse charset + */ + + const atcharset = _compileAtrule( 'charset' ); + + /** + * Parse namespace + */ + + const atnamespace = _compileAtrule( 'namespace' ); + + /** + * Parse non-block at-rules + */ + + function _compileAtrule( name ) { + const re = new RegExp( '^@' + name + '\\s*([^;]+);' ); + return function() { + const pos = position(); + const m = match( re ); + if ( ! m ) { + return; + } + const ret = { type: name }; + ret[ name ] = m[ 1 ].trim(); + return pos( ret ); + }; + } + + /** + * Parse at rule. + */ + + function atrule() { + if ( css[ 0 ] !== '@' ) { + return; + } + + return atkeyframes() || + atmedia() || + atcustommedia() || + atsupports() || + atimport() || + atcharset() || + atnamespace() || + atdocument() || + atpage() || + athost() || + atfontface(); + } + + /** + * Parse rule. + */ + + function rule() { + const pos = position(); + const sel = selector(); + + if ( ! sel ) { + return error( 'selector missing' ); + } + comments(); + + return pos( { + type: 'rule', + selectors: sel, + declarations: declarations(), + } ); + } + + return addParent( stylesheet() ); +} + +/** + * Trim `str`. + */ + +function trim( str ) { + return str ? str.replace( /^\s+|\s+$/g, '' ) : ''; +} + +/** + * Adds non-enumerable parent node reference to each node. + */ + +function addParent( obj, parent ) { + const isNode = obj && typeof obj.type === 'string'; + const childParent = isNode ? obj : parent; + + for ( const k in obj ) { + const value = obj[ k ]; + if ( Array.isArray( value ) ) { + value.forEach( function( v ) { + addParent( v, childParent ); + } ); + } else if ( value && typeof value === 'object' ) { + addParent( value, childParent ); + } + } + + if ( isNode ) { + Object.defineProperty( obj, 'parent', { + configurable: true, + writable: true, + enumerable: false, + value: parent || null, + } ); + } + + return obj; +} + +/* eslint-enable @wordpress/no-unused-vars-before-return */ diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compiler.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compiler.js new file mode 100644 index 0000000000000..1835dd6392583 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compiler.js @@ -0,0 +1,50 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * Expose `Compiler`. + */ + +export default Compiler; + +/** + * Initialize a compiler. + */ + +function Compiler( opts ) { + this.options = opts || {}; +} + +/** + * Emit `str` + */ + +Compiler.prototype.emit = function( str ) { + return str; +}; + +/** + * Visit `node`. + */ + +Compiler.prototype.visit = function( node ) { + return this[ node.type ]( node ); +}; + +/** + * Map visit over array of `nodes`, optionally using a `delim` + */ + +Compiler.prototype.mapVisit = function( nodes, delim ) { + let buf = ''; + delim = delim || ''; + + for ( let i = 0, length = nodes.length; i < length; i++ ) { + buf += this.visit( nodes[ i ] ); + if ( delim && i < length - 1 ) { + buf += this.emit( delim ); + } + } + + return buf; +}; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js new file mode 100644 index 0000000000000..6b3f0468c5ad6 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js @@ -0,0 +1,205 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * External dependencies + */ +import inherits from 'inherits'; + +/** + * Internal dependencies + */ +import Base from './compiler'; + +/** + * Expose compiler. + */ + +export default Compiler; + +/** + * Initialize a new `Compiler`. + */ + +function Compiler( options ) { + Base.call( this, options ); +} + +/** + * Inherit from `Base.prototype`. + */ + +inherits( Compiler, Base ); + +/** + * Compile `node`. + */ + +Compiler.prototype.compile = function( node ) { + return node.stylesheet + .rules.map( this.visit, this ) + .join( '' ); +}; + +/** + * Visit comment node. + */ + +Compiler.prototype.comment = function( node ) { + return this.emit( '', node.position ); +}; + +/** + * Visit import node. + */ + +Compiler.prototype.import = function( node ) { + return this.emit( '@import ' + node.import + ';', node.position ); +}; + +/** + * Visit media node. + */ + +Compiler.prototype.media = function( node ) { + return this.emit( '@media ' + node.media, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit document node. + */ + +Compiler.prototype.document = function( node ) { + const doc = '@' + ( node.vendor || '' ) + 'document ' + node.document; + + return this.emit( doc, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit charset node. + */ + +Compiler.prototype.charset = function( node ) { + return this.emit( '@charset ' + node.charset + ';', node.position ); +}; + +/** + * Visit namespace node. + */ + +Compiler.prototype.namespace = function( node ) { + return this.emit( '@namespace ' + node.namespace + ';', node.position ); +}; + +/** + * Visit supports node. + */ + +Compiler.prototype.supports = function( node ) { + return this.emit( '@supports ' + node.supports, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit keyframes node. + */ + +Compiler.prototype.keyframes = function( node ) { + return this.emit( '@' + + ( node.vendor || '' ) + + 'keyframes ' + + node.name, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.keyframes ) + + this.emit( '}' ); +}; + +/** + * Visit keyframe node. + */ + +Compiler.prototype.keyframe = function( node ) { + const decls = node.declarations; + + return this.emit( node.values.join( ',' ), node.position ) + + this.emit( '{' ) + + this.mapVisit( decls ) + + this.emit( '}' ); +}; + +/** + * Visit page node. + */ + +Compiler.prototype.page = function( node ) { + const sel = node.selectors.length ? + node.selectors.join( ', ' ) : + ''; + + return this.emit( '@page ' + sel, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.declarations ) + + this.emit( '}' ); +}; + +/** + * Visit font-face node. + */ + +Compiler.prototype[ 'font-face' ] = function( node ) { + return this.emit( '@font-face', node.position ) + + this.emit( '{' ) + + this.mapVisit( node.declarations ) + + this.emit( '}' ); +}; + +/** + * Visit host node. + */ + +Compiler.prototype.host = function( node ) { + return this.emit( '@host', node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit custom-media node. + */ + +Compiler.prototype[ 'custom-media' ] = function( node ) { + return this.emit( '@custom-media ' + node.name + ' ' + node.media + ';', node.position ); +}; + +/** + * Visit rule node. + */ + +Compiler.prototype.rule = function( node ) { + const decls = node.declarations; + if ( ! decls.length ) { + return ''; + } + + return this.emit( node.selectors.join( ',' ), node.position ) + + this.emit( '{' ) + + this.mapVisit( decls ) + + this.emit( '}' ); +}; + +/** + * Visit declaration node. + */ + +Compiler.prototype.declaration = function( node ) { + return this.emit( node.property + ':' + node.value, node.position ) + this.emit( ';' ); +}; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js new file mode 100644 index 0000000000000..cc15526e14946 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js @@ -0,0 +1,290 @@ +/* eslint-disable @wordpress/no-unused-vars-before-return */ + +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * External dependencies + */ +import inherits from 'inherits'; + +/** + * Internal dependencies + */ +import Base from './compiler'; + +/** + * Expose compiler. + */ + +export default Compiler; + +/** + * Initialize a new `Compiler`. + */ + +function Compiler( options ) { + options = options || {}; + Base.call( this, options ); + this.indentation = options.indent; +} + +/** + * Inherit from `Base.prototype`. + */ + +inherits( Compiler, Base ); + +/** + * Compile `node`. + */ + +Compiler.prototype.compile = function( node ) { + return this.stylesheet( node ); +}; + +/** + * Visit stylesheet node. + */ + +Compiler.prototype.stylesheet = function( node ) { + return this.mapVisit( node.stylesheet.rules, '\n\n' ); +}; + +/** + * Visit comment node. + */ + +Compiler.prototype.comment = function( node ) { + return this.emit( this.indent() + '/*' + node.comment + '*/', node.position ); +}; + +/** + * Visit import node. + */ + +Compiler.prototype.import = function( node ) { + return this.emit( '@import ' + node.import + ';', node.position ); +}; + +/** + * Visit media node. + */ + +Compiler.prototype.media = function( node ) { + return ( + this.emit( '@media ' + node.media, node.position ) + + this.emit( + ' {\n' + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit document node. + */ + +Compiler.prototype.document = function( node ) { + const doc = '@' + ( node.vendor || '' ) + 'document ' + node.document; + + return ( + this.emit( doc, node.position ) + + this.emit( + ' ' + + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit charset node. + */ + +Compiler.prototype.charset = function( node ) { + return this.emit( '@charset ' + node.charset + ';', node.position ); +}; + +/** + * Visit namespace node. + */ + +Compiler.prototype.namespace = function( node ) { + return this.emit( '@namespace ' + node.namespace + ';', node.position ); +}; + +/** + * Visit supports node. + */ + +Compiler.prototype.supports = function( node ) { + return ( + this.emit( '@supports ' + node.supports, node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit keyframes node. + */ + +Compiler.prototype.keyframes = function( node ) { + return ( + this.emit( '@' + ( node.vendor || '' ) + 'keyframes ' + node.name, node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.keyframes, '\n' ) + + this.emit( + this.indent( -1 ) + + '}' + ) + ); +}; + +/** + * Visit keyframe node. + */ + +Compiler.prototype.keyframe = function( node ) { + const decls = node.declarations; + + return ( + this.emit( this.indent() ) + + this.emit( node.values.join( ', ' ), node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( decls, '\n' ) + + this.emit( + this.indent( -1 ) + + '\n' + + this.indent() + '}\n' + ) + ); +}; + +/** + * Visit page node. + */ + +Compiler.prototype.page = function( node ) { + const sel = node.selectors.length ? + node.selectors.join( ', ' ) + ' ' : + ''; + + return this.emit( '@page ' + sel, node.position ) + + this.emit( '{\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( node.declarations, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n}' ); +}; + +/** + * Visit font-face node. + */ + +Compiler.prototype[ 'font-face' ] = function( node ) { + return this.emit( '@font-face ', node.position ) + + this.emit( '{\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( node.declarations, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n}' ); +}; + +/** + * Visit host node. + */ + +Compiler.prototype.host = function( node ) { + return ( + this.emit( '@host', node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit custom-media node. + */ + +Compiler.prototype[ 'custom-media' ] = function( node ) { + return this.emit( '@custom-media ' + node.name + ' ' + node.media + ';', node.position ); +}; + +/** + * Visit rule node. + */ + +Compiler.prototype.rule = function( node ) { + const indent = this.indent(); + const decls = node.declarations; + if ( ! decls.length ) { + return ''; + } + + return this.emit( node.selectors.map( function( s ) { + return indent + s; + } ).join( ',\n' ), node.position ) + + this.emit( ' {\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( decls, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n' + this.indent() + '}' ); +}; + +/** + * Visit declaration node. + */ + +Compiler.prototype.declaration = function( node ) { + return this.emit( this.indent() ) + + this.emit( node.property + ': ' + node.value, node.position ) + + this.emit( ';' ); +}; + +/** + * Increase, decrease or return current indentation. + */ + +Compiler.prototype.indent = function( level ) { + this.level = this.level || 1; + + if ( null !== level ) { + this.level += level; + return ''; + } + + return Array( this.level ).join( this.indentation || ' ' ); +}; + +/* eslint-enable @wordpress/no-unused-vars-before-return */ diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/index.js b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/index.js new file mode 100644 index 0000000000000..2dcc4d4475f02 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/ast/stringify/index.js @@ -0,0 +1,32 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * Internal dependencies + */ +import Compressed from './compress'; +import Identity from './identity'; + +/** + * Stringfy the given AST `node`. + * + * Options: + * + * - `compress` space-optimized output + * - `sourcemap` return an object with `.code` and `.map` + * + * @param {Object} node + * @param {Object} [options] + * @return {string} + */ + +export default function( node, options ) { + options = options || {}; + + const compiler = options.compress ? + new Compressed( options ) : + new Identity( options ); + + const code = compiler.compile( node ); + return code; +} diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/index.js b/gb-src/packages/block-editor/src/utils/transform-styles/index.js new file mode 100644 index 0000000000000..b0f1d66fe5511 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import traverse from './traverse'; +import urlRewrite from './transforms/url-rewrite'; +import wrap from './transforms/wrap'; + +/** + * Applies a series of CSS rule transforms to wrap selectors inside a given class and/or rewrite URLs depending on the parameters passed. + * + * @param {Array} styles CSS rules. + * @param {string} wrapperClassName Wrapper Class Name. + * @return {Array} converted rules. + */ +const transformStyles = ( styles, wrapperClassName = '' ) => { + return map( styles, ( { css, baseURL } ) => { + const transforms = []; + if ( wrapperClassName ) { + transforms.push( wrap( wrapperClassName ) ); + } + if ( baseURL ) { + transforms.push( urlRewrite( baseURL ) ); + } + if ( transforms.length ) { + return traverse( css, compose( transforms ) ); + } + + return css; + } ); +}; + +export default transformStyles; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/test/__snapshots__/traverse.js.snap b/gb-src/packages/block-editor/src/utils/transform-styles/test/__snapshots__/traverse.js.snap new file mode 100644 index 0000000000000..1ff3cab7d6336 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/test/__snapshots__/traverse.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSS traverse Should traverse the CSS 1`] = ` +"namespace h1 { +color: red; +}" +`; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/test/traverse.js b/gb-src/packages/block-editor/src/utils/transform-styles/test/traverse.js new file mode 100644 index 0000000000000..50d6d8da95bc3 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/test/traverse.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import traverse from '../traverse'; + +describe( 'CSS traverse', () => { + it( 'Should traverse the CSS', () => { + const input = `h1 { color: red; }`; + const output = traverse( input, ( node ) => { + if ( node.type === 'rule' ) { + return { + ...node, + selectors: node.selectors.map( ( selector ) => 'namespace ' + selector ), + }; + } + + return node; + } ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/url-rewrite.js.snap b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/url-rewrite.js.snap new file mode 100644 index 0000000000000..48aaf43221e7d --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/url-rewrite.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`URL rewrite should not replace absolute paths 1`] = ` +"h1 { +background: url(/images/test.png); +}" +`; + +exports[`URL rewrite should not replace remote paths 1`] = ` +"h1 { +background: url(http://wp.org/images/test.png); +}" +`; + +exports[`URL rewrite should replace complex relative paths 1`] = ` +"h1 { +background: url(http://wp-site.local/themes/gut/images/test.png); +}" +`; + +exports[`URL rewrite should replace relative paths 1`] = ` +"h1 { +background: url(http://wp-site.local/themes/gut/css/images/test.png); +}" +`; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap new file mode 100644 index 0000000000000..9060dda747029 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSS selector wrap should ignore font-face selectors 1`] = ` +"@font-face { +font-family: myFirstFont; +src: url(sansation_light.woff); +}" +`; + +exports[`CSS selector wrap should ignore keyframes 1`] = ` +"@keyframes edit-post__fade-in-animation { +from { +opacity: 0; +} +}" +`; + +exports[`CSS selector wrap should ignore selectors 1`] = ` +".my-namespace h1, +body { +color: red; +}" +`; + +exports[`CSS selector wrap should replace root tags 1`] = ` +".my-namespace, +.my-namespace h1 { +color: red; +}" +`; + +exports[`CSS selector wrap should wrap multiple selectors 1`] = ` +".my-namespace h1, +.my-namespace h2 { +color: red; +}" +`; + +exports[`CSS selector wrap should wrap regular selectors 1`] = ` +".my-namespace h1 { +color: red; +}" +`; + +exports[`CSS selector wrap should replace :root selectors 1`] = ` +".my-namespace { +--my-color: #ff0000; +}" +`; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/url-rewrite.js b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/url-rewrite.js new file mode 100644 index 0000000000000..abbbf0754187e --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/url-rewrite.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import traverse from '../../traverse'; +import rewrite from '../url-rewrite'; + +describe( 'URL rewrite', () => { + it( 'should replace relative paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should replace complex relative paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(../images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should not replace absolute paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(/images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should not replace remote paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(http://wp.org/images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js new file mode 100644 index 0000000000000..2a39ff3db3115 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import traverse from '../../traverse'; +import wrap from '../wrap'; + +describe( 'CSS selector wrap', () => { + it( 'should wrap regular selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = `h1 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should wrap multiple selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = `h1, h2 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore selectors', () => { + const callback = wrap( '.my-namespace', 'body' ); + const input = `h1, body { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should replace root tags', () => { + const callback = wrap( '.my-namespace' ); + const input = `body, h1 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore keyframes', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + @keyframes edit-post__fade-in-animation { + from { + opacity: 0; + } + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore font-face selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + @font-face { + font-family: myFirstFont; + src: url(sansation_light.woff); + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should replace :root selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + :root { + --my-color: #ff0000; + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/url-rewrite.js b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/url-rewrite.js new file mode 100644 index 0000000000000..4516fffa2196d --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/url-rewrite.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { parse, resolve } from 'url'; + +/** + * Return `true` if the given path is http/https. + * + * @param {string} filePath path + * + * @return {boolean} is remote path. + */ +function isRemotePath( filePath ) { + return /^(?:https?:)?\/\//.test( filePath ); +} + +/** + * Return `true` if the given filePath is an absolute url. + * + * @param {string} filePath path + * + * @return {boolean} is absolute path. + */ +function isAbsolutePath( filePath ) { + return /^\/(?!\/)/.test( filePath ); +} + +/** + * Whether or not the url should be inluded. + * + * @param {Object} meta url meta info + * + * @return {boolean} is valid. + */ +function isValidURL( meta ) { + // ignore hashes or data uris + if ( meta.value.indexOf( 'data:' ) === 0 || meta.value.indexOf( '#' ) === 0 ) { + return false; + } + + if ( isAbsolutePath( meta.value ) ) { + return false; + } + + // do not handle the http/https urls if `includeRemote` is false + if ( isRemotePath( meta.value ) ) { + return false; + } + + return true; +} + +/** + * Get the absolute path of the url, relative to the basePath + * + * @param {string} str the url + * @param {string} baseURL base URL + * + * @return {string} the full path to the file + */ +function getResourcePath( str, baseURL ) { + const pathname = parse( str ).pathname; + const filePath = resolve( baseURL, pathname ); + + return filePath; +} + +/** + * Process the single `url()` pattern + * + * @param {string} baseURL the base URL for relative URLs + * @return {Promise} the Promise + */ +function processURL( baseURL ) { + return function( meta ) { + const URL = getResourcePath( meta.value, baseURL ); + return { + ...meta, + newUrl: + 'url(' + + meta.before + + meta.quote + + URL + + meta.quote + + meta.after + + ')', + }; + }; +} + +/** + * Get all `url()`s, and return the meta info + * + * @param {string} value decl.value + * + * @return {Array} the urls + */ +function getURLs( value ) { + const reg = /url\((\s*)(['"]?)(.+?)\2(\s*)\)/g; + let match; + const URLs = []; + + while ( ( match = reg.exec( value ) ) !== null ) { + const meta = { + source: match[ 0 ], + before: match[ 1 ], + quote: match[ 2 ], + value: match[ 3 ], + after: match[ 4 ], + }; + if ( isValidURL( meta ) ) { + URLs.push( meta ); + } + } + return URLs; +} + +/** + * Replace the raw value's `url()` segment to the new value + * + * @param {string} raw the raw value + * @param {Array} URLs the URLs to replace + * + * @return {string} the new value + */ +function replaceURLs( raw, URLs ) { + URLs.forEach( ( item ) => { + raw = raw.replace( item.source, item.newUrl ); + } ); + + return raw; +} + +const rewrite = ( rootURL ) => ( node ) => { + if ( node.type === 'declaration' ) { + const updatedURLs = getURLs( node.value ).map( processURL( rootURL ) ); + return { + ...node, + value: replaceURLs( node.value, updatedURLs ), + }; + } + + return node; +}; + +export default rewrite; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/transforms/wrap.js b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/wrap.js new file mode 100644 index 0000000000000..d7fe709138fa8 --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/transforms/wrap.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * @constant string IS_ROOT_TAG Regex to check if the selector is a root tag selector. + */ +const IS_ROOT_TAG = /^(body|html|:root).*$/; + +const wrap = ( namespace, ignore = [] ) => ( node ) => { + const updateSelector = ( selector ) => { + if ( includes( ignore, selector.trim() ) ) { + return selector; + } + + // Anything other than a root tag is always prefixed. + {if ( ! selector.match( IS_ROOT_TAG ) ) { + return namespace + ' ' + selector; + }} + + // HTML and Body elements cannot be contained within our container so lets extract their styles. + return selector.replace( /^(body|html|:root)/, namespace ); + }; + + if ( node.type === 'rule' ) { + return { + ...node, + selectors: node.selectors.map( updateSelector ), + }; + } + + return node; +}; + +export default wrap; diff --git a/gb-src/packages/block-editor/src/utils/transform-styles/traverse.js b/gb-src/packages/block-editor/src/utils/transform-styles/traverse.js new file mode 100644 index 0000000000000..6a0cc298697ee --- /dev/null +++ b/gb-src/packages/block-editor/src/utils/transform-styles/traverse.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import traverse from 'traverse'; + +/** + * Internal dependencies + */ +import { parse, stringify } from './ast'; + +function traverseCSS( css, callback ) { + try { + const parsed = parse( css ); + + const updated = traverse.map( parsed, function( node ) { + if ( ! node ) { + return node; + } + const updatedNode = callback( node ); + return this.update( updatedNode ); + } ); + + return stringify( updated ); + } catch ( err ) { + // eslint-disable-next-line no-console + console.warn( 'Error while traversing the CSS: ' + err ); + + return null; + } +} + +export default traverseCSS; diff --git a/gb-src/packages/block-library/.npmrc b/gb-src/packages/block-library/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/block-library/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/block-library/CHANGELOG.md b/gb-src/packages/block-library/CHANGELOG.md new file mode 100644 index 0000000000000..c76eacd5a4bee --- /dev/null +++ b/gb-src/packages/block-library/CHANGELOG.md @@ -0,0 +1,119 @@ +## 2.7.0 (2019-08-05) + +### Enhancements + +- Heading block uses `has-text-align-*` class names rather than inline style for text alignment. +- Verse block uses `has-text-align-*` class names rather than inline style for text alignment. + +### Bug Fixes + +- Fixed insertion of columns in the table block, which now inserts columns for all table sections ([#16410](https://github.com/WordPress/gutenberg/pull/16410)) + +## 2.6.0 (2019-06-12) + +- Fixed an issue with creating upgraded embed blocks that are not registered ([#15883](https://github.com/WordPress/gutenberg/issues/15883)). + +## 2.5.0 (2019-05-21) + +- Add vertical alignment controls to Columns Block ([#13899](https://github.com/WordPress/gutenberg/pull/13899/)). +- Add vertical alignment controls to Media & Text Block ([#13989](https://github.com/WordPress/gutenberg/pull/13989)). +- Add `wide` and `full` alignments to Archives block ([#14533](https://github.com/WordPress/gutenberg/pull/14533)). +- Add `wide` and `full` alignments to Categories block ([#14533](https://github.com/WordPress/gutenberg/pull/14533)). +- Add all alignment options to RSS block ([#14533](https://github.com/WordPress/gutenberg/pull/14533)). +- Add all alignment options to Search block ([#14533](https://github.com/WordPress/gutenberg/pull/14533)). +- Add image fill option and focal point picker to Media & Text block ([#14445](https://github.com/WordPress/gutenberg/pull/14445)). +- Updated the edit flow of the `image` block, updated the edit icon and unified the image editing in only one UI based on `MediaPlaceholder` + +### Bug Fixes + +- fix uncaught error in `columns` block due to accessing a property on an object that might be undefined [#14605](https://github.com/WordPress/gutenberg/pull/14605) + +## 2.3.0 (2019-03-06) + +### New Feature + +- Add background color controls for the table block. +- Add new `RSS` block ([#7966](https://github.com/WordPress/gutenberg/pull/7966)). +- Add new `Search` block ([#13583](https://github.com/WordPress/gutenberg/pull/13583)). + +## 2.2.12 (2019-01-03) + +## 2.2.11 (2018-12-18) + +## 2.2.10 (2018-12-12) + +## 2.2.9 (2018-11-30) + +## 2.2.8 (2018-11-30) + +## 2.2.7 (2018-11-22) + +## 2.2.6 (2018-11-21) + +## 2.2.5 (2018-11-20) + +## 2.2.4 (2018-11-15) + +## 2.2.3 (2018-11-12) + +### Bug Fixes + +- Add a minimum width for the audio block to fixed floated audio blocks. + +## 2.2.2 (2018-11-12) + +### Polish + +- Columns Block: Improve usability while editing columns. + +## 2.2.1 (2018-11-09) + +## 2.2.0 (2018-11-09) + +### New Features + +- Gallery Block: Add screen reader support for order of images in gallery. + +## 2.1.8 (2018-11-03) + +### Polish + +- File Block: Create file blocks when dropping multiple files at once. + +## 2.1.7 (2018-10-30) + +## 2.1.6 (2018-10-30) + +### Bug Fixes + +- Classic Block: Prevent theme styles from italicising the italicise button. +- Gallery Block: Fix the "Remove Image" button appearing blank when an image is focussed. + +## 2.1.5 (2018-10-29) + +## 2.1.4 (2018-10-22) + +### Bug Fixes + +- Video Block: Set correct media types for the poster image. + +## 2.1.3 (2018-10-19) + +## 2.1.2 (2018-10-18) + +## 2.1.0 (2018-10-10) + +### New Features + +- Include the classic block if `wp.oldEditor` is defined. +- Include the HTML block. + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +### Deprecations + +- Attribute type coercion has been deprecated. Omit the source to preserve type via serialized comment demarcation. diff --git a/gb-src/packages/block-library/README.md b/gb-src/packages/block-library/README.md new file mode 100644 index 0000000000000..a572fa1c39754 --- /dev/null +++ b/gb-src/packages/block-library/README.md @@ -0,0 +1,34 @@ +# Block library + +Block library for the WordPress editor. + +## Installation + +Install the module + +```bash +npm install @wordpress/block-library --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="registerCoreBlocks" href="#registerCoreBlocks">#</a> **registerCoreBlocks** + +Function to register core blocks provided by the block editor. + +_Usage_ + +```js +import { registerCoreBlocks } from '@wordpress/block-library'; + +registerCoreBlocks(); +``` + + +<!-- END TOKEN(Autogenerated API docs) --> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/block-library/package.json b/gb-src/packages/block-library/package.json new file mode 100644 index 0000000000000..84f5691b7ce7f --- /dev/null +++ b/gb-src/packages/block-library/package.json @@ -0,0 +1,58 @@ +{ + "name": "@wordpress/block-library", + "version": "2.9.13", + "description": "Block library for the WordPress editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "blocks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-library/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-library" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:../a11y", + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/autop": "file:../autop", + "@wordpress/blob": "file:../blob", + "@wordpress/block-editor": "file:../block-editor", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/date": "file:../date", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/editor": "file:../editor", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", + "@wordpress/server-side-render": "file:../server-side-render", + "@wordpress/url": "file:../url", + "@wordpress/viewport": "file:../viewport", + "classnames": "^2.2.5", + "fast-average-color": "4.3.0", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "moment": "^2.22.1", + "tinycolor2": "^1.4.1", + "url": "^0.11.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/block-library/src/archives/edit.js b/gb-src/packages/block-library/src/archives/edit.js new file mode 100644 index 0000000000000..6909b70ccc427 --- /dev/null +++ b/gb-src/packages/block-library/src/archives/edit.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { + PanelBody, + ToggleControl, + Disabled, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import ServerSideRender from '@wordpress/server-side-render'; + +export default function ArchivesEdit( { attributes, setAttributes } ) { + const { showPostCounts, displayAsDropdown } = attributes; + + return ( + <> + <InspectorControls> + <PanelBody title={ __( 'Archives Settings' ) }> + <ToggleControl + label={ __( 'Display as Dropdown' ) } + checked={ displayAsDropdown } + onChange={ () => setAttributes( { displayAsDropdown: ! displayAsDropdown } ) } + /> + <ToggleControl + label={ __( 'Show Post Counts' ) } + checked={ showPostCounts } + onChange={ () => setAttributes( { showPostCounts: ! showPostCounts } ) } + /> + </PanelBody> + </InspectorControls> + <Disabled> + <ServerSideRender block="core/archives" attributes={ attributes } /> + </Disabled> + </> + ); +} diff --git a/gb-src/packages/block-library/src/archives/editor.scss b/gb-src/packages/block-library/src/archives/editor.scss new file mode 100644 index 0000000000000..550b3c563d5f0 --- /dev/null +++ b/gb-src/packages/block-library/src/archives/editor.scss @@ -0,0 +1,3 @@ +.block-editor ul.wp-block-archives { + padding-left: 2.5em; +} diff --git a/gb-src/packages/block-library/src/archives/icon.js b/gb-src/packages/block-library/src/archives/icon.js new file mode 100644 index 0000000000000..074a16c79458f --- /dev/null +++ b/gb-src/packages/block-library/src/archives/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M21 6V20C21 21.1 20.1 22 19 22H5C3.89 22 3 21.1 3 20L3.01 6C3.01 4.9 3.89 4 5 4H6V2H8V4H16V2H18V4H19C20.1 4 21 4.9 21 6ZM5 8H19V6H5V8ZM19 20V10H5V20H19ZM11 12H17V14H11V12ZM17 16H11V18H17V16ZM7 12H9V14H7V12ZM9 18V16H7V18H9Z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/archives/index.js b/gb-src/packages/block-library/src/archives/index.js new file mode 100644 index 0000000000000..aa4c172ddedf6 --- /dev/null +++ b/gb-src/packages/block-library/src/archives/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/archives'; + +export const settings = { + title: __( 'Archives' ), + description: __( 'Display a monthly archive of your posts.' ), + icon, + category: 'widgets', + supports: { + align: true, + html: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/archives/index.php b/gb-src/packages/block-library/src/archives/index.php new file mode 100644 index 0000000000000..172b1c7c8407e --- /dev/null +++ b/gb-src/packages/block-library/src/archives/index.php @@ -0,0 +1,143 @@ +<?php +/** + * Server-side rendering of the `core/archives` block. + * + * @package WordPress + */ + +/** + * Renders the `core/archives` block on server. + * + * @see WP_Widget_Archives + * + * @param array $attributes The block attributes. + * + * @return string Returns the post content with archives added. + */ +function render_block_core_archives( $attributes ) { + $show_post_count = ! empty( $attributes['showPostCounts'] ); + + $class = 'wp-block-archives'; + + if ( isset( $attributes['align'] ) ) { + $class .= " align{$attributes['align']}"; + } + + if ( isset( $attributes['className'] ) ) { + $class .= " {$attributes['className']}"; + } + + if ( ! empty( $attributes['displayAsDropdown'] ) ) { + + $class .= ' wp-block-archives-dropdown'; + + $dropdown_id = esc_attr( uniqid( 'wp-block-archives-' ) ); + $title = __( 'Archives' ); + + /** This filter is documented in wp-includes/widgets/class-wp-widget-archives.php */ + $dropdown_args = apply_filters( + 'widget_archives_dropdown_args', + array( + 'type' => 'monthly', + 'format' => 'option', + 'show_post_count' => $show_post_count, + ) + ); + + $dropdown_args['echo'] = 0; + + $archives = wp_get_archives( $dropdown_args ); + + switch ( $dropdown_args['type'] ) { + case 'yearly': + $label = __( 'Select Year' ); + break; + case 'monthly': + $label = __( 'Select Month' ); + break; + case 'daily': + $label = __( 'Select Day' ); + break; + case 'weekly': + $label = __( 'Select Week' ); + break; + default: + $label = __( 'Select Post' ); + break; + } + + $label = esc_attr( $label ); + + $block_content = '<label class="screen-reader-text" for="' . $dropdown_id . '">' . $title . '</label> + <select id="' . $dropdown_id . '" name="archive-dropdown" onchange="document.location.href=this.options[this.selectedIndex].value;"> + <option value="">' . $label . '</option>' . $archives . '</select>'; + + return sprintf( + '<div class="%1$s">%2$s</div>', + esc_attr( $class ), + $block_content + ); + } + + $class .= ' wp-block-archives-list'; + + /** This filter is documented in wp-includes/widgets/class-wp-widget-archives.php */ + $archives_args = apply_filters( + 'widget_archives_args', + array( + 'type' => 'monthly', + 'show_post_count' => $show_post_count, + ) + ); + + $archives_args['echo'] = 0; + + $archives = wp_get_archives( $archives_args ); + + $classnames = esc_attr( $class ); + + if ( empty( $archives ) ) { + + return sprintf( + '<div class="%1$s">%2$s</div>', + $classnames, + __( 'No archives to show.' ) + ); + } + + return sprintf( + '<ul class="%1$s">%2$s</ul>', + $classnames, + $archives + ); +} + +/** + * Register archives block. + */ +function register_block_core_archives() { + register_block_type( + 'core/archives', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'displayAsDropdown' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'showPostCounts' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'render_callback' => 'render_block_core_archives', + ) + ); +} +add_action( 'init', 'register_block_core_archives' ); diff --git a/gb-src/packages/block-library/src/audio/block.json b/gb-src/packages/block-library/src/audio/block.json new file mode 100644 index 0000000000000..c3bfc5fe13fe5 --- /dev/null +++ b/gb-src/packages/block-library/src/audio/block.json @@ -0,0 +1,38 @@ +{ + "name": "core/audio", + "category": "common", + "attributes": { + "src": { + "type": "string", + "source": "attribute", + "selector": "audio", + "attribute": "src" + }, + "caption": { + "type": "string", + "source": "html", + "selector": "figcaption" + }, + "id": { + "type": "number" + }, + "autoplay": { + "type": "boolean", + "source": "attribute", + "selector": "audio", + "attribute": "autoplay" + }, + "loop": { + "type": "boolean", + "source": "attribute", + "selector": "audio", + "attribute": "loop" + }, + "preload": { + "type": "string", + "source": "attribute", + "selector": "audio", + "attribute": "preload" + } + } +} diff --git a/gb-src/packages/block-library/src/audio/edit.js b/gb-src/packages/block-library/src/audio/edit.js new file mode 100644 index 0000000000000..15387acbd90c7 --- /dev/null +++ b/gb-src/packages/block-library/src/audio/edit.js @@ -0,0 +1,223 @@ +/** + * WordPress dependencies + */ +import { getBlobByURL, isBlobURL } from '@wordpress/blob'; +import { compose } from '@wordpress/compose'; +import { + Disabled, + IconButton, + PanelBody, + SelectControl, + ToggleControl, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { + BlockControls, + BlockIcon, + InspectorControls, + MediaPlaceholder, + RichText, +} from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import icon from './icon'; + +/** + * Internal dependencies + */ +import { createUpgradedEmbedBlock } from '../embed/util'; + +const ALLOWED_MEDIA_TYPES = [ 'audio' ]; + +class AudioEdit extends Component { + constructor() { + super( ...arguments ); + // edit component has its own src in the state so it can be edited + // without setting the actual value outside of the edit UI + this.state = { + editing: ! this.props.attributes.src, + }; + + this.toggleAttribute = this.toggleAttribute.bind( this ); + this.onSelectURL = this.onSelectURL.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + } + + componentDidMount() { + const { + attributes, + mediaUpload, + noticeOperations, + setAttributes, + } = this.props; + const { id, src = '' } = attributes; + + if ( ! id && isBlobURL( src ) ) { + const file = getBlobByURL( src ); + + if ( file ) { + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ { id: mediaId, url } ] ) => { + setAttributes( { id: mediaId, src: url } ); + }, + onError: ( e ) => { + setAttributes( { src: undefined, id: undefined } ); + this.setState( { editing: true } ); + noticeOperations.createErrorNotice( e ); + }, + allowedTypes: ALLOWED_MEDIA_TYPES, + } ); + } + } + } + + toggleAttribute( attribute ) { + return ( newValue ) => { + this.props.setAttributes( { [ attribute ]: newValue } ); + }; + } + + onSelectURL( newSrc ) { + const { attributes, setAttributes } = this.props; + const { src } = attributes; + + // Set the block's src from the edit component's state, and switch off + // the editing UI. + if ( newSrc !== src ) { + // Check if there's an embed block that handles this URL. + const embedBlock = createUpgradedEmbedBlock( + { attributes: { url: newSrc } } + ); + if ( undefined !== embedBlock ) { + this.props.onReplace( embedBlock ); + return; + } + setAttributes( { src: newSrc, id: undefined } ); + } + + this.setState( { editing: false } ); + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + getAutoplayHelp( checked ) { + return checked ? __( 'Note: Autoplaying audio may cause usability issues for some visitors.' ) : null; + } + + render() { + const { autoplay, caption, loop, preload, src } = this.props.attributes; + const { setAttributes, isSelected, className, noticeUI } = this.props; + const { editing } = this.state; + const switchToEditing = () => { + this.setState( { editing: true } ); + }; + const onSelectAudio = ( media ) => { + if ( ! media || ! media.url ) { + // in this case there was an error and we should continue in the editing state + // previous attributes should be removed because they may be temporary blob urls + setAttributes( { src: undefined, id: undefined } ); + switchToEditing(); + return; + } + // sets the block's attribute and updates the edit component from the + // selected media, then switches off the editing UI + setAttributes( { src: media.url, id: media.id } ); + this.setState( { src: media.url, editing: false } ); + }; + if ( editing ) { + return ( + <MediaPlaceholder + icon={ <BlockIcon icon={ icon } /> } + className={ className } + onSelect={ onSelectAudio } + onSelectURL={ this.onSelectURL } + accept="audio/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ this.props.attributes } + notices={ noticeUI } + onError={ this.onUploadError } + /> + ); + } + + return ( + <> + <BlockControls> + <Toolbar> + <IconButton + className="components-icon-button components-toolbar__control" + label={ __( 'Edit audio' ) } + onClick={ switchToEditing } + icon="edit" + /> + </Toolbar> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Audio Settings' ) }> + <ToggleControl + label={ __( 'Autoplay' ) } + onChange={ this.toggleAttribute( 'autoplay' ) } + checked={ autoplay } + help={ this.getAutoplayHelp } + /> + <ToggleControl + label={ __( 'Loop' ) } + onChange={ this.toggleAttribute( 'loop' ) } + checked={ loop } + /> + <SelectControl + label={ __( 'Preload' ) } + value={ undefined !== preload ? preload : 'none' } + // `undefined` is required for the preload attribute to be unset. + onChange={ ( value ) => setAttributes( { preload: ( 'none' !== value ) ? value : undefined } ) } + options={ [ + { value: 'auto', label: __( 'Auto' ) }, + { value: 'metadata', label: __( 'Metadata' ) }, + { value: 'none', label: __( 'None' ) }, + ] } + /> + </PanelBody> + </InspectorControls> + <figure className={ className }> + { /* + Disable the audio tag so the user clicking on it won't play the + file or change the position slider when the controls are enabled. + */ } + <Disabled> + <audio controls="controls" src={ src } /> + </Disabled> + { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + onChange={ ( value ) => setAttributes( { caption: value } ) } + inlineToolbar + /> + ) } + </figure> + </> + ); + } +} +export default compose( [ + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + const { __experimentalMediaUpload } = getSettings(); + return { + mediaUpload: __experimentalMediaUpload, + }; + } ), + withNotices, +] )( AudioEdit ); diff --git a/gb-src/packages/block-library/src/audio/editor.scss b/gb-src/packages/block-library/src/audio/editor.scss new file mode 100644 index 0000000000000..f99b695f6c9dd --- /dev/null +++ b/gb-src/packages/block-library/src/audio/editor.scss @@ -0,0 +1,5 @@ +.wp-block-audio { + // Remove the left and right margin the figure is born with. + margin-left: 0; + margin-right: 0; +} diff --git a/gb-src/packages/block-library/src/audio/icon.js b/gb-src/packages/block-library/src/audio/icon.js new file mode 100644 index 0000000000000..4b871506bf478 --- /dev/null +++ b/gb-src/packages/block-library/src/audio/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="m12 3l0.01 10.55c-0.59-0.34-1.27-0.55-2-0.55-2.22 0-4.01 1.79-4.01 4s1.79 4 4.01 4 3.99-1.79 3.99-4v-10h4v-4h-6zm-1.99 16c-1.1 0-2-0.9-2-2s0.9-2 2-2 2 0.9 2 2-0.9 2-2 2z" /></SVG>; diff --git a/gb-src/packages/block-library/src/audio/index.js b/gb-src/packages/block-library/src/audio/index.js new file mode 100644 index 0000000000000..c6ce546e37f6e --- /dev/null +++ b/gb-src/packages/block-library/src/audio/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Audio' ), + description: __( 'Embed a simple audio player.' ), + icon, + transforms, + supports: { + align: true, + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/audio/save.js b/gb-src/packages/block-library/src/audio/save.js new file mode 100644 index 0000000000000..13b32be648abb --- /dev/null +++ b/gb-src/packages/block-library/src/audio/save.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { autoplay, caption, loop, preload, src } = attributes; + + return ( + <figure> + <audio controls="controls" src={ src } autoPlay={ autoplay } loop={ loop } preload={ preload } /> + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/audio/style.scss b/gb-src/packages/block-library/src/audio/style.scss new file mode 100644 index 0000000000000..64d79cafd85dc --- /dev/null +++ b/gb-src/packages/block-library/src/audio/style.scss @@ -0,0 +1,17 @@ +.wp-block-audio { + // Supply caption styles to audio blocks, even if the theme hasn't opted in. + // Reason being: the new markup, <figcaptions>, are not likely to be styled in the majority of existing themes, + // so we supply the styles so as to not appear broken or unstyled in those themes. + figcaption { + @include caption-style(); + } + + // Show full-width when not aligned. + audio { + width: 100%; + + // The browser natively applies a 300px width to the audio block. + // We restore this as a min-width instead, for alignments. + min-width: 300px; + } +} diff --git a/gb-src/packages/block-library/src/audio/theme.scss b/gb-src/packages/block-library/src/audio/theme.scss new file mode 100644 index 0000000000000..03e2b5e04333a --- /dev/null +++ b/gb-src/packages/block-library/src/audio/theme.scss @@ -0,0 +1,3 @@ +.wp-block-audio figcaption { + @include caption-style-theme(); +} diff --git a/gb-src/packages/block-library/src/audio/transforms.js b/gb-src/packages/block-library/src/audio/transforms.js new file mode 100644 index 0000000000000..01396eda20dff --- /dev/null +++ b/gb-src/packages/block-library/src/audio/transforms.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { createBlobURL } from '@wordpress/blob'; +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'files', + isMatch( files ) { + return files.length === 1 && files[ 0 ].type.indexOf( 'audio/' ) === 0; + }, + transform( files ) { + const file = files[ 0 ]; + // We don't need to upload the media directly here + // It's already done as part of the `componentDidMount` + // in the audio block + const block = createBlock( 'core/audio', { + src: createBlobURL( file ), + } ); + + return block; + }, + }, + { + type: 'shortcode', + tag: 'audio', + attributes: { + src: { + type: 'string', + shortcode: ( { named: { src } } ) => { + return src; + }, + }, + loop: { + type: 'string', + shortcode: ( { named: { loop } } ) => { + return loop; + }, + }, + autoplay: { + type: 'string', + shortcode: ( { named: { autoplay } } ) => { + return autoplay; + }, + }, + preload: { + type: 'string', + shortcode: ( { named: { preload } } ) => { + return preload; + }, + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/block/edit-panel/editor.scss b/gb-src/packages/block-library/src/block/edit-panel/editor.scss new file mode 100644 index 0000000000000..72ad4bb3feac5 --- /dev/null +++ b/gb-src/packages/block-library/src/block/edit-panel/editor.scss @@ -0,0 +1,76 @@ +.block-editor-block-list__layout .reusable-block-edit-panel { + align-items: center; + background: $light-gray-100; + color: $dark-gray-500; + display: flex; + flex-wrap: wrap; + font-family: $default-font; + font-size: $default-font-size; + position: relative; + top: -$block-padding; + margin: 0 (-$block-padding); + padding: $grid-size $block-padding; + + // Elevate the reusable blocks toolbar above the clickthrough overlay. + z-index: z-index(".block-editor-block-list__layout .reusable-block-edit-panel"); + + // Use opacity to work in various editor styles. + border: $border-width dashed $dark-opacity-light-500; + border-bottom: none; + + // Show a smaller padding when nested. + .block-editor-block-list__layout & { + margin: 0 (-$block-padding); + padding: $grid-size $block-padding; + } + + .reusable-block-edit-panel__spinner { + margin: 0 5px; + } + + .reusable-block-edit-panel__info { + margin-right: auto; + } + + .reusable-block-edit-panel__label { + margin-right: $grid-size; + white-space: nowrap; + font-weight: 600; + } + + .reusable-block-edit-panel__title { + flex: 1 1 100%; + font-size: 14px; + height: 30px; + margin: $grid-size-small 0 $grid-size; + } + + .components-button.reusable-block-edit-panel__button { + // Prevent button shrinking in IE11 when other items have a 100% flex basis. + // This should be safe to apply in all browsers because we don't want these + // buttons to shrink anyway. + flex-shrink: 0; + } + + @include break-large() { + flex-wrap: nowrap; + + .reusable-block-edit-panel__title { + margin: 0; + } + + .components-button.reusable-block-edit-panel__button { + margin: 0 0 0 5px; + } + } +} + +.editor-block-list__layout .is-selected .reusable-block-edit-panel { + border-color: $dark-opacity-light-800; + border-left-color: transparent; + + .is-dark-theme & { + border-color: $light-opacity-light-800; + border-left-color: transparent; + } +} diff --git a/gb-src/packages/block-library/src/block/edit-panel/index.js b/gb-src/packages/block-library/src/block/edit-panel/index.js new file mode 100644 index 0000000000000..0bc064d25203c --- /dev/null +++ b/gb-src/packages/block-library/src/block/edit-panel/index.js @@ -0,0 +1,110 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { Component, createRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { ESCAPE } from '@wordpress/keycodes'; +import { withInstanceId } from '@wordpress/compose'; + +class ReusableBlockEditPanel extends Component { + constructor() { + super( ...arguments ); + + this.titleField = createRef(); + this.editButton = createRef(); + this.handleFormSubmit = this.handleFormSubmit.bind( this ); + this.handleTitleChange = this.handleTitleChange.bind( this ); + this.handleTitleKeyDown = this.handleTitleKeyDown.bind( this ); + } + + componentDidMount() { + // Select the input text when the form opens. + if ( this.props.isEditing && this.titleField.current ) { + this.titleField.current.select(); + } + } + + componentDidUpdate( prevProps ) { + // Select the input text only once when the form opens. + if ( ! prevProps.isEditing && this.props.isEditing ) { + this.titleField.current.select(); + } + // Move focus back to the Edit button after pressing the Escape key or Save. + if ( ( prevProps.isEditing || prevProps.isSaving ) && ! this.props.isEditing && ! this.props.isSaving ) { + this.editButton.current.focus(); + } + } + + handleFormSubmit( event ) { + event.preventDefault(); + this.props.onSave(); + } + + handleTitleChange( event ) { + this.props.onChangeTitle( event.target.value ); + } + + handleTitleKeyDown( event ) { + if ( event.keyCode === ESCAPE ) { + event.stopPropagation(); + this.props.onCancel(); + } + } + + render() { + const { isEditing, title, isSaving, isEditDisabled, onEdit, instanceId } = this.props; + + return ( + <> + { ( ! isEditing && ! isSaving ) && ( + <div className="reusable-block-edit-panel"> + <b className="reusable-block-edit-panel__info"> + { title } + </b> + <Button + ref={ this.editButton } + isLarge + className="reusable-block-edit-panel__button" + disabled={ isEditDisabled } + onClick={ onEdit } + > + { __( 'Edit' ) } + </Button> + </div> + ) } + { ( isEditing || isSaving ) && ( + <form className="reusable-block-edit-panel" onSubmit={ this.handleFormSubmit }> + <label + htmlFor={ `reusable-block-edit-panel__title-${ instanceId }` } + className="reusable-block-edit-panel__label" + > + { __( 'Name:' ) } + </label> + <input + ref={ this.titleField } + type="text" + disabled={ isSaving } + className="reusable-block-edit-panel__title" + value={ title } + onChange={ this.handleTitleChange } + onKeyDown={ this.handleTitleKeyDown } + id={ `reusable-block-edit-panel__title-${ instanceId }` } + /> + <Button + type="submit" + isLarge + isBusy={ isSaving } + disabled={ ! title || isSaving } + className="reusable-block-edit-panel__button" + > + { __( 'Save' ) } + </Button> + </form> + ) } + </> + ); + } +} + +export default withInstanceId( ReusableBlockEditPanel ); diff --git a/gb-src/packages/block-library/src/block/edit.js b/gb-src/packages/block-library/src/block/edit.js new file mode 100644 index 0000000000000..7e450327085df --- /dev/null +++ b/gb-src/packages/block-library/src/block/edit.js @@ -0,0 +1,195 @@ +/** + * External dependencies + */ +import { partial } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { Placeholder, Spinner, Disabled } from '@wordpress/components'; +import { + withSelect, + withDispatch, +} from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { + BlockEditorProvider, + BlockList, + WritingFlow, +} from '@wordpress/block-editor'; +import { compose } from '@wordpress/compose'; +import { parse, serialize } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import ReusableBlockEditPanel from './edit-panel'; +import ReusableBlockIndicator from './indicator'; + +class ReusableBlockEdit extends Component { + constructor( { reusableBlock } ) { + super( ...arguments ); + + this.startEditing = this.startEditing.bind( this ); + this.stopEditing = this.stopEditing.bind( this ); + this.setBlocks = this.setBlocks.bind( this ); + this.setTitle = this.setTitle.bind( this ); + this.save = this.save.bind( this ); + + if ( reusableBlock ) { + // Start in edit mode when we're working with a newly created reusable block + this.state = { + isEditing: reusableBlock.isTemporary, + title: reusableBlock.title, + blocks: parse( reusableBlock.content ), + }; + } else { + // Start in preview mode when we're working with an existing reusable block + this.state = { + isEditing: false, + title: null, + blocks: [], + }; + } + } + + componentDidMount() { + if ( ! this.props.reusableBlock ) { + this.props.fetchReusableBlock(); + } + } + + componentDidUpdate( prevProps ) { + if ( prevProps.reusableBlock !== this.props.reusableBlock && this.state.title === null ) { + this.setState( { + title: this.props.reusableBlock.title, + blocks: parse( this.props.reusableBlock.content ), + } ); + } + } + + startEditing() { + const { reusableBlock } = this.props; + this.setState( { + isEditing: true, + title: reusableBlock.title, + blocks: parse( reusableBlock.content ), + } ); + } + + stopEditing() { + this.setState( { + isEditing: false, + title: null, + blocks: [], + } ); + } + + setBlocks( blocks ) { + this.setState( { blocks } ); + } + + setTitle( title ) { + this.setState( { title } ); + } + + save() { + const { onChange, onSave } = this.props; + const { blocks, title } = this.state; + const content = serialize( blocks ); + onChange( { title, content } ); + onSave(); + + this.stopEditing(); + } + + render() { + const { isSelected, reusableBlock, isFetching, isSaving, canUpdateBlock, settings } = this.props; + const { isEditing, title, blocks } = this.state; + + if ( ! reusableBlock && isFetching ) { + return <Placeholder><Spinner /></Placeholder>; + } + + if ( ! reusableBlock ) { + return <Placeholder>{ __( 'Block has been deleted or is unavailable.' ) }</Placeholder>; + } + + let element = ( + <BlockEditorProvider + settings={ settings } + value={ blocks } + onChange={ this.setBlocks } + onInput={ this.setBlocks } + > + <WritingFlow> + <BlockList /> + </WritingFlow> + </BlockEditorProvider> + ); + + if ( ! isEditing ) { + element = <Disabled>{ element }</Disabled>; + } + + return ( + <div className="block-library-block__reusable-block-container"> + { ( isSelected || isEditing ) && ( + <ReusableBlockEditPanel + isEditing={ isEditing } + title={ title !== null ? title : reusableBlock.title } + isSaving={ isSaving && ! reusableBlock.isTemporary } + isEditDisabled={ ! canUpdateBlock } + onEdit={ this.startEditing } + onChangeTitle={ this.setTitle } + onSave={ this.save } + onCancel={ this.stopEditing } + /> + ) } + { ! isSelected && ! isEditing && <ReusableBlockIndicator title={ reusableBlock.title } /> } + { element } + </div> + ); + } +} + +export default compose( [ + withSelect( ( select, ownProps ) => { + const { + __experimentalGetReusableBlock: getReusableBlock, + __experimentalIsFetchingReusableBlock: isFetchingReusableBlock, + __experimentalIsSavingReusableBlock: isSavingReusableBlock, + } = select( 'core/editor' ); + const { canUser } = select( 'core' ); + const { + __experimentalGetParsedReusableBlock, + getSettings, + } = select( 'core/block-editor' ); + const { ref } = ownProps.attributes; + const reusableBlock = getReusableBlock( ref ); + + return { + reusableBlock, + isFetching: isFetchingReusableBlock( ref ), + isSaving: isSavingReusableBlock( ref ), + blocks: reusableBlock ? __experimentalGetParsedReusableBlock( reusableBlock.id ) : null, + canUpdateBlock: !! reusableBlock && ! reusableBlock.isTemporary && !! canUser( 'update', 'blocks', ref ), + settings: getSettings(), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { + __experimentalFetchReusableBlocks: fetchReusableBlocks, + __experimentalUpdateReusableBlock: updateReusableBlock, + __experimentalSaveReusableBlock: saveReusableBlock, + } = dispatch( 'core/editor' ); + const { ref } = ownProps.attributes; + + return { + fetchReusableBlock: partial( fetchReusableBlocks, ref ), + onChange: partial( updateReusableBlock, ref ), + onSave: partial( saveReusableBlock, ref ), + }; + } ), +] )( ReusableBlockEdit ); diff --git a/gb-src/packages/block-library/src/block/editor.scss b/gb-src/packages/block-library/src/block/editor.scss new file mode 100644 index 0000000000000..cdeaf9ad0ef6a --- /dev/null +++ b/gb-src/packages/block-library/src/block/editor.scss @@ -0,0 +1,3 @@ +.edit-post-visual-editor .block-library-block__reusable-block-container .block-editor-writing-flow__click-redirect { + height: auto; +} diff --git a/gb-src/packages/block-library/src/block/index.js b/gb-src/packages/block-library/src/block/index.js new file mode 100644 index 0000000000000..f66fca566f09a --- /dev/null +++ b/gb-src/packages/block-library/src/block/index.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; + +export const name = 'core/block'; + +export const settings = { + title: __( 'Reusable Block' ), + category: 'reusable', + description: __( 'Create content, and save it for you and other contributors to reuse across your site. Update the block, and the changes apply everywhere it’s used.' ), + supports: { + customClassName: false, + html: false, + inserter: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/block/index.php b/gb-src/packages/block-library/src/block/index.php new file mode 100644 index 0000000000000..24d2ab570a9ec --- /dev/null +++ b/gb-src/packages/block-library/src/block/index.php @@ -0,0 +1,48 @@ +<?php +/** + * Server-side rendering of the `core/block` block. + * + * @package WordPress + */ + +/** + * Renders the `core/block` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Rendered HTML of the referenced block. + */ +function render_block_core_block( $attributes ) { + if ( empty( $attributes['ref'] ) ) { + return ''; + } + + $reusable_block = get_post( $attributes['ref'] ); + if ( ! $reusable_block || 'wp_block' !== $reusable_block->post_type ) { + return ''; + } + + if ( 'publish' !== $reusable_block->post_status || ! empty( $reusable_block->post_password ) ) { + return ''; + } + + return do_blocks( $reusable_block->post_content ); +} + +/** + * Registers the `core/block` block. + */ +function register_block_core_block() { + register_block_type( + 'core/block', + array( + 'attributes' => array( + 'ref' => array( + 'type' => 'number', + ), + ), + 'render_callback' => 'render_block_core_block', + ) + ); +} +add_action( 'init', 'register_block_core_block' ); diff --git a/gb-src/packages/block-library/src/block/indicator/editor.scss b/gb-src/packages/block-library/src/block/indicator/editor.scss new file mode 100644 index 0000000000000..08ebbce4674fa --- /dev/null +++ b/gb-src/packages/block-library/src/block/indicator/editor.scss @@ -0,0 +1,12 @@ +.block-editor-block-list__layout .reusable-block-indicator { + background: $white; + border: $border-width dashed $light-gray-500; + color: $dark-gray-500; + top: -$block-padding; + height: 30px; + padding: $grid-size-small; + position: absolute; + z-index: z-index(".block-editor-block-list__layout .reusable-block-indicator"); + width: 30px; + right: -$block-padding; +} diff --git a/gb-src/packages/block-library/src/block/indicator/index.js b/gb-src/packages/block-library/src/block/indicator/index.js new file mode 100644 index 0000000000000..7e5310feceadd --- /dev/null +++ b/gb-src/packages/block-library/src/block/indicator/index.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { Tooltip, Dashicon } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; + +function ReusableBlockIndicator( { title } ) { + // translators: %s: title/name of the reusable block + const tooltipText = sprintf( __( 'Reusable Block: %s' ), title ); + return ( + <Tooltip text={ tooltipText }> + <span className="reusable-block-indicator"> + <Dashicon icon="controls-repeat" /> + </span> + </Tooltip> + ); +} + +export default ReusableBlockIndicator; diff --git a/gb-src/packages/block-library/src/button/block.json b/gb-src/packages/block-library/src/button/block.json new file mode 100644 index 0000000000000..ebe5748d489e2 --- /dev/null +++ b/gb-src/packages/block-library/src/button/block.json @@ -0,0 +1,53 @@ +{ + "name": "core/button", + "category": "layout", + "attributes": { + "url": { + "type": "string", + "source": "attribute", + "selector": "a", + "attribute": "href" + }, + "title": { + "type": "string", + "source": "attribute", + "selector": "a", + "attribute": "title" + }, + "text": { + "type": "string", + "source": "html", + "selector": "a" + }, + "backgroundColor": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "customBackgroundColor": { + "type": "string" + }, + "customTextColor": { + "type": "string" + }, + "linkTarget": { + "type": "string", + "source": "attribute", + "selector": "a", + "attribute": "target" + }, + "rel": { + "type": "string", + "source": "attribute", + "selector": "a", + "attribute": "rel" + }, + "placeholder": { + "type": "string" + }, + "borderRadius": { + "type": "number" + } + } +} diff --git a/gb-src/packages/block-library/src/button/deprecated.js b/gb-src/packages/block-library/src/button/deprecated.js new file mode 100644 index 0000000000000..5e1951c60ec64 --- /dev/null +++ b/gb-src/packages/block-library/src/button/deprecated.js @@ -0,0 +1,270 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + RichText, + getColorClassName, +} from '@wordpress/block-editor'; + +const colorsMigration = ( attributes ) => { + return omit( { + ...attributes, + customTextColor: attributes.textColor && '#' === attributes.textColor[ 0 ] ? attributes.textColor : undefined, + customBackgroundColor: attributes.color && '#' === attributes.color[ 0 ] ? attributes.color : undefined, + }, [ 'color', 'textColor' ] ); +}; + +const blockAttributes = { + url: { + type: 'string', + source: 'attribute', + selector: 'a', + attribute: 'href', + }, + title: { + type: 'string', + source: 'attribute', + selector: 'a', + attribute: 'title', + }, + text: { + type: 'string', + source: 'html', + selector: 'a', + }, +}; + +const deprecated = [ + { + attributes: { + ...blockAttributes, + align: { + type: 'string', + default: 'none', + }, + backgroundColor: { + type: 'string', + }, + textColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + customTextColor: { + type: 'string', + }, + linkTarget: { + type: 'string', + source: 'attribute', + selector: 'a', + attribute: 'target', + }, + rel: { + type: 'string', + source: 'attribute', + selector: 'a', + attribute: 'rel', + }, + placeholder: { + type: 'string', + }, + }, + isEligible( attribute ) { + return attribute.className && attribute.className.includes( 'is-style-squared' ); + }, + migrate( attributes ) { + let newClassName = attributes.className; + if ( newClassName ) { + newClassName = newClassName.replace( /is-style-squared[\s]?/, '' ).trim(); + } + return { + ...attributes, + className: newClassName ? newClassName : undefined, + borderRadius: 0, + }; + }, + save( { attributes } ) { + const { + backgroundColor, + customBackgroundColor, + customTextColor, + linkTarget, + rel, + text, + textColor, + title, + url, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + + const buttonClasses = classnames( 'wp-block-button__link', { + 'has-text-color': textColor || customTextColor, + [ textClass ]: textClass, + 'has-background': backgroundColor || customBackgroundColor, + [ backgroundClass ]: backgroundClass, + } ); + + const buttonStyle = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + }; + + return ( + <div> + <RichText.Content + tagName="a" + className={ buttonClasses } + href={ url } + title={ title } + style={ buttonStyle } + value={ text } + target={ linkTarget } + rel={ rel } + /> + </div> + ); + }, + }, + { + attributes: { + ...blockAttributes, + align: { + type: 'string', + default: 'none', + }, + backgroundColor: { + type: 'string', + }, + textColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + customTextColor: { + type: 'string', + }, + }, + save( { attributes } ) { + const { + url, + text, + title, + backgroundColor, + textColor, + customBackgroundColor, + customTextColor, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + + const buttonClasses = classnames( 'wp-block-button__link', { + 'has-text-color': textColor || customTextColor, + [ textClass ]: textClass, + 'has-background': backgroundColor || customBackgroundColor, + [ backgroundClass ]: backgroundClass, + } ); + + const buttonStyle = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + }; + + return ( + <div> + <RichText.Content + tagName="a" + className={ buttonClasses } + href={ url } + title={ title } + style={ buttonStyle } + value={ text } + /> + </div> + ); + }, + migrate: colorsMigration, + }, + { + attributes: { + ...blockAttributes, + color: { + type: 'string', + }, + textColor: { + type: 'string', + }, + align: { + type: 'string', + default: 'none', + }, + }, + save( { attributes } ) { + const { url, text, title, align, color, textColor } = attributes; + + const buttonStyle = { + backgroundColor: color, + color: textColor, + }; + + const linkClass = 'wp-block-button__link'; + + return ( + <div className={ `align${ align }` }> + <RichText.Content + tagName="a" + className={ linkClass } + href={ url } + title={ title } + style={ buttonStyle } + value={ text } + /> + </div> + ); + }, + migrate: colorsMigration, + }, + { + attributes: { + ...blockAttributes, + color: { + type: 'string', + }, + textColor: { + type: 'string', + }, + align: { + type: 'string', + default: 'none', + }, + }, + save( { attributes } ) { + const { url, text, title, align, color, textColor } = attributes; + + return ( + <div className={ `align${ align }` } style={ { backgroundColor: color } }> + <RichText.Content + tagName="a" + href={ url } + title={ title } + style={ { color: textColor } } + value={ text } + /> + </div> + ); + }, + migrate: colorsMigration, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/button/edit.js b/gb-src/packages/block-library/src/button/edit.js new file mode 100644 index 0000000000000..a57ab89ca94f6 --- /dev/null +++ b/gb-src/packages/block-library/src/button/edit.js @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Component, + useCallback, +} from '@wordpress/element'; +import { + compose, + withInstanceId, +} from '@wordpress/compose'; +import { + BaseControl, + PanelBody, + RangeControl, + TextControl, + ToggleControl, + withFallbackStyles, +} from '@wordpress/components'; +import { + URLInput, + RichText, + ContrastChecker, + InspectorControls, + withColors, + PanelColorSettings, +} from '@wordpress/block-editor'; + +const { getComputedStyle } = window; + +const applyFallbackStyles = withFallbackStyles( ( node, ownProps ) => { + const { textColor, backgroundColor } = ownProps; + const backgroundColorValue = backgroundColor && backgroundColor.color; + const textColorValue = textColor && textColor.color; + //avoid the use of querySelector if textColor color is known and verify if node is available. + const textNode = ! textColorValue && node ? node.querySelector( '[contenteditable="true"]' ) : null; + return { + fallbackBackgroundColor: backgroundColorValue || ! node ? undefined : getComputedStyle( node ).backgroundColor, + fallbackTextColor: textColorValue || ! textNode ? undefined : getComputedStyle( textNode ).color, + }; +} ); + +const NEW_TAB_REL = 'noreferrer noopener'; +const MIN_BORDER_RADIUS_VALUE = 0; +const MAX_BORDER_RADIUS_VALUE = 50; +const INITIAL_BORDER_RADIUS_POSITION = 5; + +function BorderPanel( { borderRadius = '', setAttributes } ) { + const setBorderRadius = useCallback( + ( newBorderRadius ) => { + setAttributes( { borderRadius: newBorderRadius } ); + }, + [ setAttributes ] + ); + return ( + <PanelBody title={ __( 'Border Settings' ) }> + <RangeControl + value={ borderRadius } + label={ __( 'Border Radius' ) } + min={ MIN_BORDER_RADIUS_VALUE } + max={ MAX_BORDER_RADIUS_VALUE } + initialPosition={ INITIAL_BORDER_RADIUS_POSITION } + allowReset + onChange={ setBorderRadius } + /> + </PanelBody> + ); +} + +class ButtonEdit extends Component { + constructor() { + super( ...arguments ); + this.nodeRef = null; + this.bindRef = this.bindRef.bind( this ); + this.onSetLinkRel = this.onSetLinkRel.bind( this ); + this.onToggleOpenInNewTab = this.onToggleOpenInNewTab.bind( this ); + } + + bindRef( node ) { + if ( ! node ) { + return; + } + this.nodeRef = node; + } + + onSetLinkRel( value ) { + this.props.setAttributes( { rel: value } ); + } + + onToggleOpenInNewTab( value ) { + const { rel } = this.props.attributes; + const linkTarget = value ? '_blank' : undefined; + + let updatedRel = rel; + if ( linkTarget && ! rel ) { + updatedRel = NEW_TAB_REL; + } else if ( ! linkTarget && rel === NEW_TAB_REL ) { + updatedRel = undefined; + } + + this.props.setAttributes( { + linkTarget, + rel: updatedRel, + } ); + } + + render() { + const { + attributes, + backgroundColor, + textColor, + setBackgroundColor, + setTextColor, + fallbackBackgroundColor, + fallbackTextColor, + setAttributes, + className, + instanceId, + isSelected, + } = this.props; + + const { + borderRadius, + linkTarget, + placeholder, + rel, + text, + title, + url, + } = attributes; + + const linkId = `wp-block-button__inline-link-${ instanceId }`; + + return ( + <div className={ className } title={ title } ref={ this.bindRef }> + <RichText + placeholder={ placeholder || __( 'Add text…' ) } + value={ text } + onChange={ ( value ) => setAttributes( { text: value } ) } + withoutInteractiveFormatting + className={ classnames( + 'wp-block-button__link', { + 'has-background': backgroundColor.color, + [ backgroundColor.class ]: backgroundColor.class, + 'has-text-color': textColor.color, + [ textColor.class ]: textColor.class, + 'no-border-radius': borderRadius === 0, + } + ) } + style={ { + backgroundColor: backgroundColor.color, + color: textColor.color, + borderRadius: borderRadius ? borderRadius + 'px' : undefined, + } } + /> + <BaseControl + label={ __( 'Link' ) } + className="wp-block-button__inline-link" + id={ linkId }> + <URLInput + className="wp-block-button__inline-link-input" + value={ url } + /* eslint-disable jsx-a11y/no-autofocus */ + // Disable Reason: The rule is meant to prevent enabling auto-focus, not disabling it. + autoFocus={ false } + /* eslint-enable jsx-a11y/no-autofocus */ + onChange={ ( value ) => setAttributes( { url: value } ) } + disableSuggestions={ ! isSelected } + id={ linkId } + isFullWidth + hasBorder + /> + </BaseControl> + <InspectorControls> + <PanelColorSettings + title={ __( 'Color Settings' ) } + colorSettings={ [ + { + value: backgroundColor.color, + onChange: setBackgroundColor, + label: __( 'Background Color' ), + }, + { + value: textColor.color, + onChange: setTextColor, + label: __( 'Text Color' ), + }, + ] } + > + <ContrastChecker + { ...{ + // Text is considered large if font size is greater or equal to 18pt or 24px, + // currently that's not the case for button. + isLargeText: false, + textColor: textColor.color, + backgroundColor: backgroundColor.color, + fallbackBackgroundColor, + fallbackTextColor, + } } + /> + </PanelColorSettings> + <BorderPanel + borderRadius={ borderRadius } + setAttributes={ setAttributes } + /> + <PanelBody title={ __( 'Link settings' ) }> + <ToggleControl + label={ __( 'Open in new tab' ) } + onChange={ this.onToggleOpenInNewTab } + checked={ linkTarget === '_blank' } + /> + <TextControl + label={ __( 'Link rel' ) } + value={ rel || '' } + onChange={ this.onSetLinkRel } + /> + </PanelBody> + </InspectorControls> + </div> + ); + } +} + +export default compose( [ + withInstanceId, + withColors( 'backgroundColor', { textColor: 'color' } ), + applyFallbackStyles, +] )( ButtonEdit ); diff --git a/gb-src/packages/block-library/src/button/editor.scss b/gb-src/packages/block-library/src/button/editor.scss new file mode 100644 index 0000000000000..5ccd04dd5b625 --- /dev/null +++ b/gb-src/packages/block-library/src/button/editor.scss @@ -0,0 +1,86 @@ +.block-editor-block-list__block[data-type="core/button"] { + &[data-align="center"] { + text-align: center; + div[data-block] { + margin-left: auto; + margin-right: auto; + } + } + + &[data-align="right"] { + /*!rtl:ignore*/ + text-align: right; + } +} + +.wp-block-button { + position: relative; + + [contenteditable] { + cursor: text; + } + + // Don't let placeholder expand parent width. + .block-editor-rich-text { + display: inline-block; + } + + // Make placeholder text white unless custom colors or outline versions are chosen. + &:not(.has-text-color):not(.is-style-outline) [data-rich-text-placeholder]::after { + color: $white; + } + + // Add outline to button on focus to indicate focus-state + .block-editor-rich-text__editable:focus { + box-shadow: 0 0 0 1px $white, 0 0 0 3px $blue-medium-500; + + // Windows' High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: -2px; + } + + // Increase placeholder opacity to meet contrast ratios. + [data-rich-text-placeholder]::after { + opacity: 0.8; + } +} + +.wp-block-button__inline-link { + color: $dark-gray-500; + height: 0; + overflow: hidden; + max-width: 290px; + + &-input__suggestions { + max-width: 290px; + } + + @include break-medium() { + max-width: 260px; + + &-input__suggestions { + max-width: 260px; + } + + } + @include break-large() { + max-width: 290px; + + &-input__suggestions { + max-width: 290px; + } + + } + + .is-selected &, + .is-typing & { + height: auto; + overflow: visible; + margin-top: $grid-size-large; + } +} + +// Display "table" is used because the button container should only wrap the content and not takes the full width. +div[data-type="core/button"] div[data-block] { + display: table; +} diff --git a/gb-src/packages/block-library/src/button/icon.js b/gb-src/packages/block-library/src/button/icon.js new file mode 100644 index 0000000000000..6e18a60a648fe --- /dev/null +++ b/gb-src/packages/block-library/src/button/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M19 6H5c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H5V8h14v8z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/button/index.js b/gb-src/packages/block-library/src/button/index.js new file mode 100644 index 0000000000000..83a01deff0554 --- /dev/null +++ b/gb-src/packages/block-library/src/button/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Button' ), + description: __( 'Prompt visitors to take action with a button-style link.' ), + icon, + keywords: [ __( 'link' ) ], + example: { + attributes: { + className: 'is-style-fill', + backgroundColor: 'vivid-green-cyan', + text: __( 'Call to Action' ), + }, + }, + supports: { + align: true, + alignWide: false, + }, + styles: [ + { name: 'fill', label: __( 'Fill' ), isDefault: true }, + { name: 'outline', label: __( 'Outline' ) }, + ], + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/button/save.js b/gb-src/packages/block-library/src/button/save.js new file mode 100644 index 0000000000000..ab2385bb75592 --- /dev/null +++ b/gb-src/packages/block-library/src/button/save.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + RichText, + getColorClassName, +} from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + backgroundColor, + borderRadius, + customBackgroundColor, + customTextColor, + linkTarget, + rel, + text, + textColor, + title, + url, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + + const buttonClasses = classnames( 'wp-block-button__link', { + 'has-text-color': textColor || customTextColor, + [ textClass ]: textClass, + 'has-background': backgroundColor || customBackgroundColor, + [ backgroundClass ]: backgroundClass, + 'no-border-radius': borderRadius === 0, + } ); + + const buttonStyle = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + borderRadius: borderRadius ? borderRadius + 'px' : undefined, + }; + + return ( + <div> + <RichText.Content + tagName="a" + className={ buttonClasses } + href={ url } + title={ title } + style={ buttonStyle } + value={ text } + target={ linkTarget } + rel={ rel } + /> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/button/style.scss b/gb-src/packages/block-library/src/button/style.scss new file mode 100644 index 0000000000000..f2ad6bf77b130 --- /dev/null +++ b/gb-src/packages/block-library/src/button/style.scss @@ -0,0 +1,53 @@ +$blocks-button__height: 56px; + +.wp-block-button { + color: $white; + + &.aligncenter { + text-align: center; + } + + &.alignright { + /*rtl:ignore*/ + text-align: right; + } +} + +.wp-block-button__link { + background-color: $dark-gray-700; + border: none; + border-radius: $blocks-button__height / 2; + box-shadow: none; + color: inherit; + cursor: pointer; + display: inline-block; + font-size: $big-font-size; + margin: 0; + padding: 12px 24px; + text-align: center; + text-decoration: none; + overflow-wrap: break-word; + + &:hover, + &:focus, + &:active, + &:visited { + color: inherit; + } +} + +.is-style-squared .wp-block-button__link { + border-radius: 0; +} +.no-border-radius.wp-block-button__link { + border-radius: 0 !important; +} + +.is-style-outline { + color: $dark-gray-700; + + .wp-block-button__link { + background-color: transparent; + border: 2px solid; + } +} diff --git a/gb-src/packages/block-library/src/calendar/edit.js b/gb-src/packages/block-library/src/calendar/edit.js new file mode 100644 index 0000000000000..243387c6dec39 --- /dev/null +++ b/gb-src/packages/block-library/src/calendar/edit.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import memoize from 'memize'; + +/** + * WordPress dependencies + */ +import { + Disabled, +} from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import ServerSideRender from '@wordpress/server-side-render'; + +class CalendarEdit extends Component { + constructor() { + super( ...arguments ); + this.getYearMonth = memoize( + this.getYearMonth.bind( this ), + { maxSize: 1 } + ); + this.getServerSideAttributes = memoize( + this.getServerSideAttributes.bind( this ), + { maxSize: 1 } + ); + } + + getYearMonth( date ) { + if ( ! date ) { + return {}; + } + const momentDate = moment( date ); + return { + year: momentDate.year(), + month: momentDate.month() + 1, + }; + } + + getServerSideAttributes( attributes, date ) { + return { + ...attributes, + ...this.getYearMonth( date ), + }; + } + + render() { + return ( + <Disabled> + <ServerSideRender + block="core/calendar" + attributes={ this.getServerSideAttributes( + this.props.attributes, + this.props.date + ) } + /> + </Disabled> + ); + } +} + +export default withSelect( ( select ) => { + const coreEditorSelect = select( 'core/editor' ); + if ( ! coreEditorSelect ) { + return; + } + const { + getEditedPostAttribute, + } = coreEditorSelect; + const postType = getEditedPostAttribute( 'type' ); + // Dates are used to overwrite year and month used on the calendar. + // This overwrite should only happen for 'post' post types. + // For other post types the calendar always displays the current month. + return { + date: postType === 'post' ? + getEditedPostAttribute( 'date' ) : + undefined, + }; +} )( CalendarEdit ); diff --git a/gb-src/packages/block-library/src/calendar/icon.js b/gb-src/packages/block-library/src/calendar/icon.js new file mode 100644 index 0000000000000..023a1902171d0 --- /dev/null +++ b/gb-src/packages/block-library/src/calendar/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M7 11h2v2H7v-2zm14-5v14c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2l.01-14c0-1.1.88-2 1.99-2h1V2h2v2h8V2h2v2h1c1.1 0 2 .9 2 2zM5 8h14V6H5v2zm14 12V10H5v10h14zm-4-7h2v-2h-2v2zm-4 0h2v-2h-2v2z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/calendar/index.js b/gb-src/packages/block-library/src/calendar/index.js new file mode 100644 index 0000000000000..b63eb51188afe --- /dev/null +++ b/gb-src/packages/block-library/src/calendar/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/calendar'; + +export const settings = { + title: __( 'Calendar' ), + description: __( 'A calendar of your site’s posts.' ), + icon, + category: 'widgets', + keywords: [ __( 'posts' ), __( 'archive' ) ], + supports: { + align: true, + }, + example: {}, + edit, +}; diff --git a/gb-src/packages/block-library/src/calendar/index.php b/gb-src/packages/block-library/src/calendar/index.php new file mode 100644 index 0000000000000..3d2e8061ba5d6 --- /dev/null +++ b/gb-src/packages/block-library/src/calendar/index.php @@ -0,0 +1,78 @@ +<?php +/** + * Server-side rendering of the `core/calendar` block. + * + * @package WordPress + */ + +/** + * Renders the `core/calendar` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the block content. + */ +function render_block_core_calendar( $attributes ) { + global $monthnum, $year; + + $previous_monthnum = $monthnum; + $previous_year = $year; + + if ( isset( $attributes['month'] ) && isset( $attributes['year'] ) ) { + $permalink_structure = get_option( 'permalink_structure' ); + if ( + strpos( $permalink_structure, '%monthnum%' ) !== false && + strpos( $permalink_structure, '%year%' ) !== false + ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.OverrideProhibited + $monthnum = $attributes['month']; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.OverrideProhibited + $year = $attributes['year']; + } + } + + $custom_class_name = empty( $attributes['className'] ) ? '' : ' ' . $attributes['className']; + $align_class_name = empty( $attributes['align'] ) ? '' : ' ' . "align{$attributes['align']}"; + + $output = sprintf( + '<div class="%1$s">%2$s</div>', + esc_attr( 'wp-block-calendar' . $custom_class_name . $align_class_name ), + get_calendar( true, false ) + ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.OverrideProhibited + $monthnum = $previous_monthnum; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.OverrideProhibited + $year = $previous_year; + + return $output; +} + +/** + * Registers the `core/calendar` block on server. + */ +function register_block_core_calendar() { + register_block_type( + 'core/calendar', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'month' => array( + 'type' => 'integer', + ), + 'year' => array( + 'type' => 'integer', + ), + ), + 'render_callback' => 'render_block_core_calendar', + ) + ); +} + +add_action( 'init', 'register_block_core_calendar' ); diff --git a/gb-src/packages/block-library/src/calendar/style.scss b/gb-src/packages/block-library/src/calendar/style.scss new file mode 100644 index 0000000000000..fb38516c38fbf --- /dev/null +++ b/gb-src/packages/block-library/src/calendar/style.scss @@ -0,0 +1,37 @@ +.wp-block-calendar { + text-align: center; + + th, + tbody td { + padding: 4px; + border: 1px solid $light-gray-500; + } + + tfoot td { + border: none; + } + + table { + width: 100%; + border-collapse: collapse; + font-family: $default-font; + } + + table th { + font-weight: 400; + background: $light-gray-300; + } + + a { + text-decoration: underline; + } + + tfoot a { + color: $blue-medium-800; + } + + table tbody, + table caption { + color: $dark-gray-600; + } +} diff --git a/gb-src/packages/block-library/src/categories/edit.js b/gb-src/packages/block-library/src/categories/edit.js new file mode 100644 index 0000000000000..9020c33d48478 --- /dev/null +++ b/gb-src/packages/block-library/src/categories/edit.js @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import { times, unescape } from 'lodash'; + +/** + * WordPress dependencies + */ +import { PanelBody, Placeholder, Spinner, ToggleControl } from '@wordpress/components'; +import { compose, withInstanceId } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; +import { InspectorControls } from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +class CategoriesEdit extends Component { + constructor() { + super( ...arguments ); + + this.toggleDisplayAsDropdown = this.toggleDisplayAsDropdown.bind( this ); + this.toggleShowPostCounts = this.toggleShowPostCounts.bind( this ); + this.toggleShowHierarchy = this.toggleShowHierarchy.bind( this ); + } + + toggleDisplayAsDropdown() { + const { attributes, setAttributes } = this.props; + const { displayAsDropdown } = attributes; + + setAttributes( { displayAsDropdown: ! displayAsDropdown } ); + } + + toggleShowPostCounts() { + const { attributes, setAttributes } = this.props; + const { showPostCounts } = attributes; + + setAttributes( { showPostCounts: ! showPostCounts } ); + } + + toggleShowHierarchy() { + const { attributes, setAttributes } = this.props; + const { showHierarchy } = attributes; + + setAttributes( { showHierarchy: ! showHierarchy } ); + } + + getCategories( parentId = null ) { + const categories = this.props.categories; + if ( ! categories || ! categories.length ) { + return []; + } + + if ( parentId === null ) { + return categories; + } + + return categories.filter( ( category ) => category.parent === parentId ); + } + + getCategoryListClassName( level ) { + return `wp-block-categories__list wp-block-categories__list-level-${ level }`; + } + + renderCategoryName( category ) { + if ( ! category.name ) { + return __( '(Untitled)' ); + } + + return unescape( category.name ).trim(); + } + + renderCategoryList() { + const { showHierarchy } = this.props.attributes; + const parentId = showHierarchy ? 0 : null; + const categories = this.getCategories( parentId ); + + return ( + <ul className={ this.getCategoryListClassName( 0 ) }> + { categories.map( ( category ) => this.renderCategoryListItem( category, 0 ) ) } + </ul> + ); + } + + renderCategoryListItem( category, level ) { + const { showHierarchy, showPostCounts } = this.props.attributes; + const childCategories = this.getCategories( category.id ); + + return ( + <li key={ category.id }> + <a href={ category.link } target="_blank" rel="noreferrer noopener">{ this.renderCategoryName( category ) }</a> + { showPostCounts && + <span className="wp-block-categories__post-count"> + { ' ' }({ category.count }) + </span> + } + + { + showHierarchy && + !! childCategories.length && ( + <ul className={ this.getCategoryListClassName( level + 1 ) }> + { childCategories.map( ( childCategory ) => this.renderCategoryListItem( childCategory, level + 1 ) ) } + </ul> + ) + } + </li> + ); + } + + renderCategoryDropdown() { + const { instanceId } = this.props; + const { showHierarchy } = this.props.attributes; + const parentId = showHierarchy ? 0 : null; + const categories = this.getCategories( parentId ); + const selectId = `blocks-category-select-${ instanceId }`; + return ( + <> + <label htmlFor={ selectId } className="screen-reader-text"> + { __( 'Categories' ) } + </label> + <select id={ selectId } className="wp-block-categories__dropdown"> + { categories.map( ( category ) => this.renderCategoryDropdownItem( category, 0 ) ) } + </select> + </> + ); + } + + renderCategoryDropdownItem( category, level ) { + const { showHierarchy, showPostCounts } = this.props.attributes; + const childCategories = this.getCategories( category.id ); + + return [ + <option key={ category.id }> + { times( level * 3, () => '\xa0' ) } + { this.renderCategoryName( category ) } + { + !! showPostCounts ? + ` (${ category.count })` : + '' + } + </option>, + showHierarchy && + !! childCategories.length && ( + childCategories.map( ( childCategory ) => this.renderCategoryDropdownItem( childCategory, level + 1 ) ) + ), + ]; + } + + render() { + const { attributes, isRequesting } = this.props; + const { displayAsDropdown, showHierarchy, showPostCounts } = attributes; + + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ __( 'Categories Settings' ) }> + <ToggleControl + label={ __( 'Display as Dropdown' ) } + checked={ displayAsDropdown } + onChange={ this.toggleDisplayAsDropdown } + /> + <ToggleControl + label={ __( 'Show Hierarchy' ) } + checked={ showHierarchy } + onChange={ this.toggleShowHierarchy } + /> + <ToggleControl + label={ __( 'Show Post Counts' ) } + checked={ showPostCounts } + onChange={ this.toggleShowPostCounts } + /> + </PanelBody> + </InspectorControls> + ); + + if ( isRequesting ) { + return ( + <> + { inspectorControls } + <Placeholder + icon="admin-post" + label={ __( 'Categories' ) } + > + <Spinner /> + </Placeholder> + </> + ); + } + + return ( + <> + { inspectorControls } + <div className={ this.props.className }> + { + displayAsDropdown ? + this.renderCategoryDropdown() : + this.renderCategoryList() + } + </div> + </> + ); + } +} +export default compose( + withSelect( ( select ) => { + const { getEntityRecords } = select( 'core' ); + const { isResolving } = select( 'core/data' ); + const query = { per_page: -1, hide_empty: true }; + + return { + categories: getEntityRecords( 'taxonomy', 'category', query ), + isRequesting: isResolving( 'core', 'getEntityRecords', [ 'taxonomy', 'category', query ] ), + }; + } ), + withInstanceId +)( CategoriesEdit ); diff --git a/gb-src/packages/block-library/src/categories/editor.scss b/gb-src/packages/block-library/src/categories/editor.scss new file mode 100644 index 0000000000000..9b199c162746b --- /dev/null +++ b/gb-src/packages/block-library/src/categories/editor.scss @@ -0,0 +1,7 @@ +.block-editor .wp-block-categories ul { + padding-left: 2.5em; + + ul { + margin-top: 6px; + } +} diff --git a/gb-src/packages/block-library/src/categories/icon.js b/gb-src/packages/block-library/src/categories/icon.js new file mode 100644 index 0000000000000..fb89fbd4ce1a1 --- /dev/null +++ b/gb-src/packages/block-library/src/categories/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="M12,2l-5.5,9h11L12,2z M12,5.84L13.93,9h-3.87L12,5.84z" /><Path d="m17.5 13c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5zm0 7c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" /><Path d="m3 21.5h8v-8h-8v8zm2-6h4v4h-4v-4z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/categories/index.js b/gb-src/packages/block-library/src/categories/index.js new file mode 100644 index 0000000000000..109b5ae0d10cb --- /dev/null +++ b/gb-src/packages/block-library/src/categories/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/categories'; + +export const settings = { + title: __( 'Categories' ), + description: __( 'Display a list of all categories.' ), + icon, + category: 'widgets', + supports: { + align: true, + html: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/categories/index.php b/gb-src/packages/block-library/src/categories/index.php new file mode 100644 index 0000000000000..159bb8b799c46 --- /dev/null +++ b/gb-src/packages/block-library/src/categories/index.php @@ -0,0 +1,120 @@ +<?php +/** + * Server-side rendering of the `core/categories` block. + * + * @package WordPress + */ + +/** + * Renders the `core/categories` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the categories list/dropdown markup. + */ +function render_block_core_categories( $attributes ) { + static $block_id = 0; + $block_id++; + + $args = array( + 'echo' => false, + 'hierarchical' => ! empty( $attributes['showHierarchy'] ), + 'orderby' => 'name', + 'show_count' => ! empty( $attributes['showPostCounts'] ), + 'title_li' => '', + ); + + if ( ! empty( $attributes['displayAsDropdown'] ) ) { + $id = 'wp-block-categories-' . $block_id; + $args['id'] = $id; + $args['show_option_none'] = __( 'Select Category' ); + $wrapper_markup = '<div class="%1$s">%2$s</div>'; + $items_markup = wp_dropdown_categories( $args ); + $type = 'dropdown'; + + if ( ! is_admin() ) { + $wrapper_markup .= build_dropdown_script_block_core_categories( $id ); + } + } else { + $wrapper_markup = '<ul class="%1$s">%2$s</ul>'; + $items_markup = wp_list_categories( $args ); + $type = 'list'; + } + + $class = "wp-block-categories wp-block-categories-{$type}"; + + if ( isset( $attributes['align'] ) ) { + $class .= " align{$attributes['align']}"; + } + + if ( isset( $attributes['className'] ) ) { + $class .= " {$attributes['className']}"; + } + + return sprintf( + $wrapper_markup, + esc_attr( $class ), + $items_markup + ); +} + +/** + * Generates the inline script for a categories dropdown field. + * + * @param string $dropdown_id ID of the dropdown field. + * + * @return string Returns the dropdown onChange redirection script. + */ +function build_dropdown_script_block_core_categories( $dropdown_id ) { + ob_start(); + ?> + <script type='text/javascript'> + /* <![CDATA[ */ + ( function() { + var dropdown = document.getElementById( '<?php echo esc_js( $dropdown_id ); ?>' ); + function onCatChange() { + if ( dropdown.options[ dropdown.selectedIndex ].value > 0 ) { + location.href = "<?php echo home_url(); ?>/?cat=" + dropdown.options[ dropdown.selectedIndex ].value; + } + } + dropdown.onchange = onCatChange; + })(); + /* ]]> */ + </script> + <?php + return ob_get_clean(); +} + +/** + * Registers the `core/categories` block on server. + */ +function register_block_core_categories() { + register_block_type( + 'core/categories', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'displayAsDropdown' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'showHierarchy' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'showPostCounts' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'render_callback' => 'render_block_core_categories', + ) + ); +} +add_action( 'init', 'register_block_core_categories' ); diff --git a/gb-src/packages/block-library/src/categories/style.scss b/gb-src/packages/block-library/src/categories/style.scss new file mode 100644 index 0000000000000..0b5eeafd989ea --- /dev/null +++ b/gb-src/packages/block-library/src/categories/style.scss @@ -0,0 +1,10 @@ +.wp-block-categories { + &.alignleft { + /*rtl:ignore*/ + margin-right: 2em; + } + &.alignright { + /*rtl:ignore*/ + margin-left: 2em; + } +} diff --git a/gb-src/packages/block-library/src/classic/block.json b/gb-src/packages/block-library/src/classic/block.json new file mode 100644 index 0000000000000..d178848a1526a --- /dev/null +++ b/gb-src/packages/block-library/src/classic/block.json @@ -0,0 +1,10 @@ +{ + "name": "core/freeform", + "category": "formatting", + "attributes": { + "content": { + "type": "string", + "source": "html" + } + } +} diff --git a/gb-src/packages/block-library/src/classic/edit.js b/gb-src/packages/block-library/src/classic/edit.js new file mode 100644 index 0000000000000..2ce77409e8e0f --- /dev/null +++ b/gb-src/packages/block-library/src/classic/edit.js @@ -0,0 +1,206 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; +import { BACKSPACE, DELETE, F10 } from '@wordpress/keycodes'; + +const { wp } = window; + +function isTmceEmpty( editor ) { + // When tinyMce is empty the content seems to be: + // <p><br data-mce-bogus="1"></p> + // avoid expensive checks for large documents + const body = editor.getBody(); + if ( body.childNodes.length > 1 ) { + return false; + } else if ( body.childNodes.length === 0 ) { + return true; + } + if ( body.childNodes[ 0 ].childNodes.length > 1 ) { + return false; + } + return /^\n?$/.test( body.innerText || body.textContent ); +} + +export default class ClassicEdit extends Component { + constructor( props ) { + super( props ); + this.initialize = this.initialize.bind( this ); + this.onSetup = this.onSetup.bind( this ); + this.focus = this.focus.bind( this ); + } + + componentDidMount() { + const { baseURL, suffix } = window.wpEditorL10n.tinymce; + + window.tinymce.EditorManager.overrideDefaults( { + base_url: baseURL, + suffix, + } ); + + if ( document.readyState === 'complete' ) { + this.initialize(); + } else { + window.addEventListener( 'DOMContentLoaded', this.initialize ); + } + } + + componentWillUnmount() { + window.addEventListener( 'DOMContentLoaded', this.initialize ); + wp.oldEditor.remove( `editor-${ this.props.clientId }` ); + } + + componentDidUpdate( prevProps ) { + const { clientId, attributes: { content } } = this.props; + + const editor = window.tinymce.get( `editor-${ clientId }` ); + + if ( prevProps.attributes.content !== content ) { + editor.setContent( content || '' ); + } + } + + initialize() { + const { clientId } = this.props; + const { settings } = window.wpEditorL10n.tinymce; + wp.oldEditor.initialize( `editor-${ clientId }`, { + tinymce: { + ...settings, + inline: true, + content_css: false, + fixed_toolbar_container: `#toolbar-${ clientId }`, + setup: this.onSetup, + }, + } ); + } + + onSetup( editor ) { + const { attributes: { content }, setAttributes } = this.props; + const { ref } = this; + let bookmark; + + this.editor = editor; + + if ( content ) { + editor.on( 'loadContent', () => editor.setContent( content ) ); + } + + editor.on( 'blur', () => { + bookmark = editor.selection.getBookmark( 2, true ); + + setAttributes( { + content: editor.getContent(), + } ); + + editor.once( 'focus', () => { + if ( bookmark ) { + editor.selection.moveToBookmark( bookmark ); + } + } ); + + return false; + } ); + + editor.on( 'mousedown touchstart', () => { + bookmark = null; + } ); + + editor.on( 'keydown', ( event ) => { + if ( ( event.keyCode === BACKSPACE || event.keyCode === DELETE ) && isTmceEmpty( editor ) ) { + // delete the block + this.props.onReplace( [] ); + event.preventDefault(); + event.stopImmediatePropagation(); + } + + const { altKey } = event; + /* + * Prevent Mousetrap from kicking in: TinyMCE already uses its own + * `alt+f10` shortcut to focus its toolbar. + */ + if ( altKey && event.keyCode === F10 ) { + event.stopPropagation(); + } + } ); + + // TODO: the following is for back-compat with WP 4.9, not needed in WP 5.0. Remove it after the release. + editor.addButton( 'kitchensink', { + tooltip: _x( 'More', 'button to expand options' ), + icon: 'dashicon dashicons-editor-kitchensink', + onClick() { + const button = this; + const active = ! button.active(); + + button.active( active ); + editor.dom.toggleClass( ref, 'has-advanced-toolbar', active ); + }, + } ); + + // Show the second, third, etc. toolbars when the `kitchensink` button is removed by a plugin. + editor.on( 'init', function() { + if ( editor.settings.toolbar1 && editor.settings.toolbar1.indexOf( 'kitchensink' ) === -1 ) { + editor.dom.addClass( ref, 'has-advanced-toolbar' ); + } + } ); + + editor.addButton( 'wp_add_media', { + tooltip: __( 'Insert Media' ), + icon: 'dashicon dashicons-admin-media', + cmd: 'WP_Medialib', + } ); + // End TODO. + + editor.on( 'init', () => { + const rootNode = this.editor.getBody(); + + // Create the toolbar by refocussing the editor. + if ( document.activeElement === rootNode ) { + rootNode.blur(); + this.editor.focus(); + } + } ); + } + + focus() { + if ( this.editor ) { + this.editor.focus(); + } + } + + onToolbarKeyDown( event ) { + // Prevent WritingFlow from kicking in and allow arrows navigation on the toolbar. + event.stopPropagation(); + // Prevent Mousetrap from moving focus to the top toolbar when pressing `alt+f10` on this block toolbar. + event.nativeEvent.stopImmediatePropagation(); + } + + render() { + const { clientId } = this.props; + + // Disable reasons: + // + // jsx-a11y/no-static-element-interactions + // - the toolbar itself is non-interactive, but must capture events + // from the KeyboardShortcuts component to stop their propagation. + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return [ + <div + key="toolbar" + id={ `toolbar-${ clientId }` } + ref={ ( ref ) => this.ref = ref } + className="block-library-classic__toolbar" + onClick={ this.focus } + data-placeholder={ __( 'Classic' ) } + onKeyDown={ this.onToolbarKeyDown } + />, + <div + key="editor" + id={ `editor-${ clientId }` } + className="wp-block-freeform block-library-rich-text__tinymce" + />, + ]; + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} diff --git a/gb-src/packages/block-library/src/classic/editor.scss b/gb-src/packages/block-library/src/classic/editor.scss new file mode 100644 index 0000000000000..186523cc549b3 --- /dev/null +++ b/gb-src/packages/block-library/src/classic/editor.scss @@ -0,0 +1,380 @@ +.wp-block-freeform.block-library-rich-text__tinymce { + p, + li { + line-height: $editor-line-height; + } + + ul, + ol { + padding-left: 2.5em; + margin-left: 0; + } + + blockquote { + margin: 0; + box-shadow: inset 0 0 0 0 $light-gray-500; + border-left: 4px solid $black; + padding-left: 1em; + } + + pre { + white-space: pre-wrap; + font-family: $editor-html-font; + font-size: $text-editor-font-size; + color: $dark-gray-800; + } + + > *:first-child { + margin-top: 0; + } + + > *:last-child { + margin-bottom: 0; + } + + &.mce-edit-focus { + outline: none; + } + + a { + color: $blue-medium-700; + } + + &:focus a[data-mce-selected] { + padding: 0 2px; + margin: 0 -2px; + border-radius: 2px; + box-shadow: 0 0 0 $border-width $blue-medium-100; + background: $blue-medium-100; + } + + code { + padding: 2px; + border-radius: 2px; + color: $dark-gray-800; + background: $light-gray-200; + font-family: $editor-html-font; + font-size: 14px; + } + + &:focus code[data-mce-selected] { + background: $light-gray-400; + } + + .alignright { + /*rtl:ignore*/ + float: right; + /*rtl:ignore*/ + margin: 0.5em 0 0.5em 1em; + } + + .alignleft { + /*rtl:ignore*/ + float: left; + /*rtl:ignore*/ + margin: 0.5em 1em 0.5em 0; + } + + .aligncenter { + display: block; + margin-left: auto; + margin-right: auto; + } + + .wp-more-tag { + width: 96%; + height: 20px; + display: block; + margin: 15px auto; + outline: 0; + cursor: default; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAADtgAAAAoBAMAAAA86gLBAAAAJFBMVEVMaXG7u7vBwcHDw8POzs68vLzGxsbMzMy+vr7AwMDQ0NDGxsYKLGzpAAAADHRSTlMA///zWf+/f///TMxNVGuqAAABwklEQVR4Ae3dMXLaQBTH4bfj8UCpx8hq0vgKvgFNemhT6Qo6gg6R+0ZahM2QLmyBJ99XWP9V5+o3jIUcLQEAAAAAAAAAAAAAAAAAAAAAAABQ8j0WL9lfTtlt18uNXAUA8O/KVtfa1tdcrOdSh9gCQAMlh1hMNbZZ1bsrsQWABsrhLRbz7z5in/32UbfUMUbkMQCAh5RfGYv82UdMdZ6HS2wjT2ILAI8r3XmM2B3WvM59vfO2xXYW2yYAENuPU8S+X/N67mKxzy225yaxBQCxLV392UdcvwV0jPVUj98ntkBWT7C7+9u2/V/vGtvXIWJ6/4rtbottWa6Ri0NUT/u72LYttrb97LHdvUXMxxrb8TO2W2TF1rYbbLG1bbGNjMi4+2Sbi1FsbbvNFlvbFtt5fDnE3d9sP1/XeIyV2Nr2U2/guZUuptNrH/dPI9eLB6SaAEBs6wPJf3/PNk9tYgsAYrv/8TFuzx/fvkFqGtrEFgDEdpcZUb7ejXy6ntrEFgDENvL6gsas4vbdyKt4DACI7TxElJv/Z7udpqFNbAFAbKduy2uU2trttM/x28UWAAAAAAAAAAAAAAAAAAAAAAAAAADgDyPwGmGTCZp7AAAAAElFTkSuQmCC); + background-size: 1900px 20px; + background-repeat: no-repeat; + background-position: center; + } + + /* Remove blue highlighting of selected images in WebKit */ + img::selection { + background-color: transparent; + } + + div.mceTemp { + -ms-user-select: element; + } + + /* Image captions */ + dl.wp-caption { + margin: 0; /* dl browser reset */ + max-width: 100%; + + a, + img { + display: block; + } + + &, + & * { + -webkit-user-drag: none; + } + + .wp-caption-dd { + padding-top: 0.5em; + margin: 0; /* browser dd reset */ + } + } + + /* WP Views */ + .wpview { + width: 99.99%; /* All IE need hasLayout, incl. 11 (ugh, not again!!) */ + position: relative; + clear: both; + margin-bottom: 16px; + border: 1px solid transparent; + + iframe { + display: block; + max-width: 100%; + background: transparent; + } + + .mce-shim { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &[data-mce-selected="2"] .mce-shim { + display: none; + } + + .loading-placeholder { + border: 1px dashed $light-gray-400; + padding: 10px; + } + + .wpview-error { + border: 1px solid $light-gray-400; + padding: 1em 0; + margin: 0; + word-wrap: break-word; + + p { + margin: 0; + text-align: center; + } + } + + &[data-mce-selected] .loading-placeholder, + &[data-mce-selected] .wpview-error { + border-color: transparent; + } + + .dashicons { + display: block; + margin: 0 auto; + width: 32px; + height: 32px; + font-size: 32px; + } + + // The following gallery styles were replicated + // from the styles applied in the tinymce skin, + // /wp-includes/js/tinymce/skins/wordpress/wp-content.css. + &.wpview-type-gallery::after { + content: ""; + display: table; + clear: both; + } + } + + .gallery img[data-mce-selected]:focus { + outline: none; + } + + .gallery a { + cursor: default; + } + + .gallery { + margin: auto -6px; + padding: 6px 0; + line-height: 1; + overflow-x: hidden; + } + + .gallery .gallery-item { + float: left; + margin: 0; + text-align: center; + padding: 6px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + } + + .gallery .gallery-caption, + .gallery .gallery-icon { + margin: 0; + } + + .gallery .gallery-caption { + font-size: 13px; + margin: 4px 0; + } + + @for $i from 1 through 9 { + .gallery-columns-#{ $i } .gallery-item { + width: #{ (100 / $i) + "%" }; + } + } + + .gallery img { + max-width: 100%; + height: auto; + border: none; + padding: 0; + } +} + +div[data-type="core/freeform"] { + .block-editor-block-list__block-edit::before { + transition: border-color 0.1s linear, box-shadow 0.1s linear; + @include reduce-motion("transition"); + border: $border-width solid $light-gray-500; + + // Windows High Contrast mode will show this outline. + outline: $border-width solid transparent; + } + + &.is-selected .block-editor-block-list__block-edit::before { + border-color: $light-gray-800; + border-left-color: transparent; + } + + // Don't show block type label for classic block + &.is-hovered .block-editor-block-list__breadcrumb { + display: none; + } + + .editor-block-contextual-toolbar + div { + margin-top: 0; + padding-top: 0; + } + + // Ensure aligned blocks at end are within the selected block. + &.is-selected .block-library-rich-text__tinymce::after { + content: ""; + display: table; + clear: both; + } +} + +// mce global styles: the toolbars may get appended to <body> +.mce-toolbar-grp { + // Not sure why this is necessary, there seems to be a skin file that + // overrides this upstream. + .mce-btn.mce-active button, + .mce-btn.mce-active:hover button, + .mce-btn.mce-active i, + .mce-btn.mce-active:hover i { + color: $dark-gray-800; + } + + // Prevent toolbar clipping on heading style in RTL languages + .mce-rtl .mce-flow-layout-item.mce-last { + margin-right: 0; + margin-left: 8px; + } + + // Prevent i tags in buttons from picking up theme editor styles. + .mce-btn i { + font-style: normal; + } +} + +.block-library-classic__toolbar { + // Only display the classic toolbar when the classic block is selected or when the + // block is empty or loading and the placeholder should be displayed. + display: none; + width: auto; + margin: 0 #{ -$block-padding }; + position: sticky; + z-index: z-index(".block-library-classic__toolbar"); + top: $block-padding; + transform: translateY(-$block-padding); + border: $border-width solid $light-gray-500; + border-bottom: none; + + // On mobile, toolbars go edge to edge. + padding: 0; + + // By using the `data-type` attribute in this selector it ensures these styles only appear + // when the block itself is selected, without it they would also show when a parent block + // is selected. + div[data-type="core/freeform"].is-selected &, + div[data-type="core/freeform"].is-typing & { + display: block; + border-color: $light-gray-800; + } + + // Remove the box shadow to mimic other Gutenberg UI. + .mce-tinymce { + box-shadow: none; + } + + @include break-small() { + padding: 0; + } + + &:empty { + display: block; + background: #f5f5f5; + border-bottom: $border-width solid #e2e4e7; + + &::before { + font-family: $default-font; + font-size: $default-font-size; + content: attr(data-placeholder); + color: #555d66; + line-height: 37px; + padding: $block-padding; + } + } + + // Overwrite inline styles. + .mce-tinymce-inline, + .mce-tinymce-inline > div, + div.mce-toolbar-grp, + div.mce-toolbar-grp > div, + .mce-menubar, + .mce-menubar > div { + height: auto !important; + width: 100% !important; + } + + .mce-container-body.mce-abs-layout { + overflow: visible; + } + + .mce-menubar, + div.mce-toolbar-grp { + position: static; + } + + // Adjust padding to not cause a jump. + .mce-toolbar-grp > div { + padding: 1px 3px; + } + + .mce-toolbar-grp .mce-toolbar:not(:first-child) { + display: none; + } + + &.has-advanced-toolbar .mce-toolbar-grp .mce-toolbar { + display: block; + } +} + diff --git a/gb-src/packages/block-library/src/classic/icon.js b/gb-src/packages/block-library/src/classic/icon.js new file mode 100644 index 0000000000000..4193982d2aa44 --- /dev/null +++ b/gb-src/packages/block-library/src/classic/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, Rect, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none" /><Path d="m20 7v10h-16v-10h16m0-2h-16c-1.1 0-1.99 0.9-1.99 2l-0.01 10c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2v-10c0-1.1-0.9-2-2-2z" /><Rect x="11" y="8" width="2" height="2" /><Rect x="11" y="11" width="2" height="2" /><Rect x="8" y="8" width="2" height="2" /><Rect x="8" y="11" width="2" height="2" /><Rect x="5" y="11" width="2" height="2" /><Rect x="5" y="8" width="2" height="2" /><Rect x="8" y="14" width="8" height="2" /><Rect x="14" y="11" width="2" height="2" /><Rect x="14" y="8" width="2" height="2" /><Rect x="17" y="11" width="2" height="2" /><Rect x="17" y="8" width="2" height="2" /></SVG> +); diff --git a/gb-src/packages/block-library/src/classic/index.js b/gb-src/packages/block-library/src/classic/index.js new file mode 100644 index 0000000000000..63cdf7713c309 --- /dev/null +++ b/gb-src/packages/block-library/src/classic/index.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: _x( 'Classic', 'block title' ), + description: __( 'Use the classic WordPress editor.' ), + icon, + supports: { + className: false, + customClassName: false, + // Hide 'Add to Reusable Blocks' on Classic blocks. Showing it causes a + // confusing UX, because of its similarity to the 'Convert to Blocks' button. + reusable: false, + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/classic/save.js b/gb-src/packages/block-library/src/classic/save.js new file mode 100644 index 0000000000000..f40af63d51a0d --- /dev/null +++ b/gb-src/packages/block-library/src/classic/save.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save( { attributes } ) { + const { content } = attributes; + + return <RawHTML>{ content }</RawHTML>; +} diff --git a/gb-src/packages/block-library/src/code/block.json b/gb-src/packages/block-library/src/code/block.json new file mode 100644 index 0000000000000..d39e5f213bbe6 --- /dev/null +++ b/gb-src/packages/block-library/src/code/block.json @@ -0,0 +1,11 @@ +{ + "name": "core/code", + "category": "formatting", + "attributes": { + "content": { + "type": "string", + "source": "text", + "selector": "code" + } + } +} diff --git a/gb-src/packages/block-library/src/code/edit.js b/gb-src/packages/block-library/src/code/edit.js new file mode 100644 index 0000000000000..2926774f46ff2 --- /dev/null +++ b/gb-src/packages/block-library/src/code/edit.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PlainText } from '@wordpress/block-editor'; +import { escape, unescape } from './utils'; + +export default function CodeEdit( { attributes, setAttributes, className } ) { + return ( + <div className={ className }> + <PlainText + value={ unescape( attributes.content ) } + onChange={ ( content ) => setAttributes( { content: escape( content ) } ) } + placeholder={ __( 'Write code…' ) } + aria-label={ __( 'Code' ) } + /> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/code/edit.native.js b/gb-src/packages/block-library/src/code/edit.native.js new file mode 100644 index 0000000000000..3f021790c2a40 --- /dev/null +++ b/gb-src/packages/block-library/src/code/edit.native.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PlainText } from '@wordpress/block-editor'; +import { escape, unescape } from './utils'; + +/** + * Block code style + */ +import styles from './theme.scss'; + +// Note: styling is applied directly to the (nested) PlainText component. Web-side components +// apply it to the container 'div' but we don't have a proper proposal for cascading styling yet. +export default function CodeEdit( props ) { + const { attributes, setAttributes, style, onFocus, onBlur } = props; + + return ( + <View> + <PlainText + value={ unescape( attributes.content ) } + style={ [ style, styles.blockCode ] } + multiline={ true } + underlineColorAndroid="transparent" + onChange={ ( content ) => setAttributes( { content: escape( content ) } ) } + placeholder={ __( 'Write code…' ) } + aria-label={ __( 'Code' ) } + isSelected={ props.isSelected } + onFocus={ onFocus } + onBlur={ onBlur } + fontFamily={ ( styles.blockCode.fontFamily ) } + /> + </View> + ); +} + diff --git a/gb-src/packages/block-library/src/code/editor.scss b/gb-src/packages/block-library/src/code/editor.scss new file mode 100644 index 0000000000000..5fb4e6e468205 --- /dev/null +++ b/gb-src/packages/block-library/src/code/editor.scss @@ -0,0 +1,68 @@ +.wp-block-code .block-editor-plain-text { + font-family: $editor-html-font; + color: $dark-gray-800; + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + @include break-small { + font-size: $default-font-size; + } + + &:focus { + box-shadow: none; + } +} + +// We should extract this to a separate components-toolbar +.components-tab-button { + display: inline-flex; + align-items: flex-end; + margin: 0; + padding: 3px; + background: none; + outline: none; + color: $dark-gray-500; + cursor: pointer; + position: relative; + height: $icon-button-size; + font-family: $default-font; + font-size: $default-font-size; + font-weight: 500; + border: 0; + + &.is-active, + &.is-active:hover { + color: $white; + } + + &:disabled { + cursor: default; + } + + & > span { + border: $border-width solid transparent; + padding: 0 6px; + box-sizing: content-box; + height: 28px; + line-height: 28px; + } + + &:hover > span, + &:focus > span { + color: $dark-gray-500; + } + + &:not(:disabled) { + &.is-active > span, + &:hover > span, + &:focus > span { + border: $border-width solid $dark-gray-500; + } + } + + &.is-active > span, + &.is-active:hover > span { + background-color: $dark-gray-500; + color: $white; + } +} diff --git a/gb-src/packages/block-library/src/code/icon.js b/gb-src/packages/block-library/src/code/icon.js new file mode 100644 index 0000000000000..35e809693a36b --- /dev/null +++ b/gb-src/packages/block-library/src/code/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="M9.4,16.6L4.8,12l4.6-4.6L8,6l-6,6l6,6L9.4,16.6z M14.6,16.6l4.6-4.6l-4.6-4.6L16,6l6,6l-6,6L14.6,16.6z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/code/index.js b/gb-src/packages/block-library/src/code/index.js new file mode 100644 index 0000000000000..e23a740b9d5e6 --- /dev/null +++ b/gb-src/packages/block-library/src/code/index.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Code' ), + description: __( 'Display code snippets that respect your spacing and tabs.' ), + icon, + example: { + attributes: { + content: __( '// A "block" is the abstract term used' ) + '\n' + + __( '// to describe units of markup that,' ) + '\n' + + __( '// when composed together, form the' ) + '\n' + + __( '// content or layout of a page.' ) + '\n' + + __( 'registerBlockType( name, settings );' ), + }, + }, + supports: { + html: false, + }, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/code/save.js b/gb-src/packages/block-library/src/code/save.js new file mode 100644 index 0000000000000..21d74669c854b --- /dev/null +++ b/gb-src/packages/block-library/src/code/save.js @@ -0,0 +1,3 @@ +export default function save( { attributes } ) { + return <pre><code>{ attributes.content }</code></pre>; +} diff --git a/gb-src/packages/block-library/src/code/test/edit.native.js b/gb-src/packages/block-library/src/code/test/edit.native.js new file mode 100644 index 0000000000000..62e9fa9adb48c --- /dev/null +++ b/gb-src/packages/block-library/src/code/test/edit.native.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import renderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import Code from '../edit'; +import { TextInput } from 'react-native'; + +describe( 'Code', () => { + it( 'renders without crashing', () => { + const component = renderer.create( <Code attributes={ { content: '' } } /> ); + const rendered = component.toJSON(); + expect( rendered ).toBeTruthy(); + } ); + + it( 'renders given text without crashing', () => { + const component = renderer.create( <Code attributes={ { content: 'sample text' } } /> ); + const testInstance = component.root; + const textInput = testInstance.findByType( TextInput ); + expect( textInput ).toBeTruthy(); + expect( textInput.props.value ).toBe( 'sample text' ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/code/test/utils.js b/gb-src/packages/block-library/src/code/test/utils.js new file mode 100644 index 0000000000000..4926eef16d528 --- /dev/null +++ b/gb-src/packages/block-library/src/code/test/utils.js @@ -0,0 +1,62 @@ +/** + * Internal dependencies + */ +import { escape, unescape } from '../utils'; + +describe( 'core/code', () => { + describe( 'escape()', () => { + it( 'should escape ampersands', () => { + const text = escape( '&' ); + expect( text ).toBe( '&' ); + } ); + + it( 'should escape opening square brackets', () => { + const text = escape( '[shortcode][/shortcode]' ); + expect( text ).toBe( '[shortcode][/shortcode]' ); + } ); + + it( 'should escape the protocol of an isolated url', () => { + const text = escape( 'https://example.com/test/' ); + expect( text ).toBe( 'https://example.com/test/' ); + } ); + + it( 'should not escape the protocol of a non isolated url', () => { + const text = escape( 'Text https://example.com/test/' ); + expect( text ).toBe( 'Text https://example.com/test/' ); + } ); + + it( 'should escape ampersands last', () => { + const text = escape( '[shortcode][/shortcode]' ); + expect( text ).toBe( '[shortcode][/shortcode]' ); + expect( text ).not.toBe( '&#91;shortcode]&#91;/shortcode]' ); + } ); + } ); + + describe( 'unescape()', () => { + it( 'should unescape escaped ampersands', () => { + const text = unescape( '&' ); + expect( text ).toBe( '&' ); + } ); + + it( 'should unescape escaped opening square brackets', () => { + const text = unescape( '[shortcode][/shortcode]' ); + expect( text ).toBe( '[shortcode][/shortcode]' ); + } ); + + it( 'should unescape the escaped protocol of an isolated url', () => { + const text = unescape( 'https://example.com/test/' ); + expect( text ).toBe( 'https://example.com/test/' ); + } ); + + it( 'should revert the result of escape()', () => { + const ampersand = unescape( escape( '&' ) ); + expect( ampersand ).toBe( '&' ); + + const squareBracket = unescape( escape( '[shortcode][/shortcode]' ) ); + expect( squareBracket ).toBe( '[shortcode][/shortcode]' ); + + const url = unescape( escape( 'https://example.com/test/' ) ); + expect( url ).toBe( 'https://example.com/test/' ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/code/theme.native.scss b/gb-src/packages/block-library/src/code/theme.native.scss new file mode 100644 index 0000000000000..668b9f92dd1f5 --- /dev/null +++ b/gb-src/packages/block-library/src/code/theme.native.scss @@ -0,0 +1,6 @@ +/* stylelint-disable font-family-no-missing-generic-family-keyword */ + +.blockCode { + font-family: $default-monospace-font; +} + diff --git a/gb-src/packages/block-library/src/code/theme.scss b/gb-src/packages/block-library/src/code/theme.scss new file mode 100644 index 0000000000000..3a0760f29dd79 --- /dev/null +++ b/gb-src/packages/block-library/src/code/theme.scss @@ -0,0 +1,8 @@ +.wp-block-code { + font-family: $editor-html-font; + font-size: $text-editor-font-size; + color: $dark-gray-800; + padding: 0.8em 1em; + border: 1px solid $light-gray-500; + border-radius: 4px; +} diff --git a/gb-src/packages/block-library/src/code/transforms.js b/gb-src/packages/block-library/src/code/transforms.js new file mode 100644 index 0000000000000..180a95bc9b4c5 --- /dev/null +++ b/gb-src/packages/block-library/src/code/transforms.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'enter', + regExp: /^```$/, + transform: () => createBlock( 'core/code' ), + }, + { + type: 'raw', + isMatch: ( node ) => ( + node.nodeName === 'PRE' && + node.children.length === 1 && + node.firstChild.nodeName === 'CODE' + ), + schema: { + pre: { + children: { + code: { + children: { + '#text': {}, + }, + }, + }, + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/code/utils.js b/gb-src/packages/block-library/src/code/utils.js new file mode 100644 index 0000000000000..fb8557a95b18e --- /dev/null +++ b/gb-src/packages/block-library/src/code/utils.js @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { flow } from 'lodash'; + +/** + * Escapes ampersands, shortcodes, and links. + * + * @param {string} content The content of a code block. + * @return {string} The given content with some characters escaped. + */ +export function escape( content ) { + return flow( + escapeAmpersands, + escapeOpeningSquareBrackets, + escapeProtocolInIsolatedUrls + )( content || '' ); +} + +/** + * Unescapes escaped ampersands, shortcodes, and links. + * + * @param {string} content Content with (maybe) escaped ampersands, shortcodes, and links. + * @return {string} The given content with escaped characters unescaped. + */ +export function unescape( content ) { + return flow( + unescapeProtocolInIsolatedUrls, + unescapeOpeningSquareBrackets, + unescapeAmpersands + )( content || '' ); +} + +/** + * Returns the given content with all its ampersand characters converted + * into their HTML entity counterpart (i.e. & => &) + * + * @param {string} content The content of a code block. + * @return {string} The given content with its ampersands converted into + * their HTML entity counterpart (i.e. & => &) + */ +function escapeAmpersands( content ) { + return content.replace( /&/g, '&' ); +} + +/** + * Returns the given content with all & HTML entities converted into &. + * + * @param {string} content The content of a code block. + * @return {string} The given content with all & HTML entities + * converted into &. + */ +function unescapeAmpersands( content ) { + return content.replace( /&/g, '&' ); +} + +/** + * Returns the given content with all opening shortcode characters converted + * into their HTML entity counterpart (i.e. [ => [). For instance, a + * shortcode like [embed] becomes [embed] + * + * This function replicates the escaping of HTML tags, where a tag like + * <strong> becomes <strong>. + * + * @param {string} content The content of a code block. + * @return {string} The given content with its opening shortcode characters + * converted into their HTML entity counterpart + * (i.e. [ => [) + */ +function escapeOpeningSquareBrackets( content ) { + return content.replace( /\[/g, '[' ); +} + +/** + * Returns the given content translating all [ into [. + * + * @param {string} content The content of a code block. + * @return {string} The given content with all [ into [. + */ +function unescapeOpeningSquareBrackets( content ) { + return content.replace( /[/g, '[' ); +} + +/** + * Converts the first two forward slashes of any isolated URL into their HTML + * counterparts (i.e. // => //). For instance, https://youtube.com/watch?x + * becomes https://youtube.com/watch?x. + * + * An isolated URL is a URL that sits in its own line, surrounded only by spacing + * characters. + * + * See https://github.com/WordPress/wordpress-develop/blob/5.1.1/src/wp-includes/class-wp-embed.php#L403 + * + * @param {string} content The content of a code block. + * @return {string} The given content with its ampersands converted into + * their HTML entity counterpart (i.e. & => &) + */ +function escapeProtocolInIsolatedUrls( content ) { + return content.replace( /^(\s*https?:)\/\/([^\s<>"]+\s*)$/m, '$1//$2' ); +} + +/** + * Converts the first two forward slashes of any isolated URL from the HTML entity + * I into /. + * + * An isolated URL is a URL that sits in its own line, surrounded only by spacing + * characters. + * + * See https://github.com/WordPress/wordpress-develop/blob/5.1.1/src/wp-includes/class-wp-embed.php#L403 + * + * @param {string} content The content of a code block. + * @return {string} The given content with the first two forward slashes of any + * isolated URL from the HTML entity I into /. + */ +function unescapeProtocolInIsolatedUrls( content ) { + return content.replace( /^(\s*https?:)//([^\s<>"]+\s*)$/m, '$1//$2' ); +} diff --git a/gb-src/packages/block-library/src/column/block.json b/gb-src/packages/block-library/src/column/block.json new file mode 100644 index 0000000000000..1d9b391731034 --- /dev/null +++ b/gb-src/packages/block-library/src/column/block.json @@ -0,0 +1,14 @@ +{ + "name": "core/column", + "category": "common", + "attributes": { + "verticalAlignment": { + "type": "string" + }, + "width": { + "type": "number", + "min": 0, + "max": 100 + } + } +} diff --git a/gb-src/packages/block-library/src/column/edit.js b/gb-src/packages/block-library/src/column/edit.js new file mode 100644 index 0000000000000..72f359f33da59 --- /dev/null +++ b/gb-src/packages/block-library/src/column/edit.js @@ -0,0 +1,140 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { forEach, find, difference } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + InnerBlocks, + BlockControls, + BlockVerticalAlignmentToolbar, + InspectorControls, +} from '@wordpress/block-editor'; +import { PanelBody, RangeControl } from '@wordpress/components'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + toWidthPrecision, + getTotalColumnsWidth, + getColumnWidths, + getAdjacentBlocks, + getRedistributedColumnWidths, +} from '../columns/utils'; + +function ColumnEdit( { + attributes, + className, + updateAlignment, + updateWidth, + hasChildBlocks, +} ) { + const { verticalAlignment, width } = attributes; + + const classes = classnames( + className, + 'block-core-columns', + { + [ `is-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + } + ); + + return ( + <div className={ classes }> + <BlockControls> + <BlockVerticalAlignmentToolbar + onChange={ updateAlignment } + value={ verticalAlignment } + /> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Column Settings' ) }> + <RangeControl + label={ __( 'Percentage width' ) } + value={ width || '' } + onChange={ updateWidth } + min={ 0 } + max={ 100 } + required + allowReset + /> + </PanelBody> + </InspectorControls> + <InnerBlocks + templateLock={ false } + renderAppender={ ( + hasChildBlocks ? + undefined : + () => <InnerBlocks.ButtonBlockAppender /> + ) } + /> + </div> + ); +} + +export default compose( + withSelect( ( select, ownProps ) => { + const { clientId } = ownProps; + const { getBlockOrder } = select( 'core/block-editor' ); + + return { + hasChildBlocks: getBlockOrder( clientId ).length > 0, + }; + } ), + withDispatch( ( dispatch, ownProps, registry ) => { + return { + updateAlignment( verticalAlignment ) { + const { clientId, setAttributes } = ownProps; + const { updateBlockAttributes } = dispatch( 'core/block-editor' ); + const { getBlockRootClientId } = registry.select( 'core/block-editor' ); + + // Update own alignment. + setAttributes( { verticalAlignment } ); + + // Reset Parent Columns Block + const rootClientId = getBlockRootClientId( clientId ); + updateBlockAttributes( rootClientId, { verticalAlignment: null } ); + }, + updateWidth( width ) { + const { clientId } = ownProps; + const { updateBlockAttributes } = dispatch( 'core/block-editor' ); + const { getBlockRootClientId, getBlocks } = registry.select( 'core/block-editor' ); + + // Constrain or expand siblings to account for gain or loss of + // total columns area. + const columns = getBlocks( getBlockRootClientId( clientId ) ); + const adjacentColumns = getAdjacentBlocks( columns, clientId ); + + // The occupied width is calculated as the sum of the new width + // and the total width of blocks _not_ in the adjacent set. + const occupiedWidth = width + getTotalColumnsWidth( + difference( columns, [ + find( columns, { clientId } ), + ...adjacentColumns, + ] ) + ); + + // Compute _all_ next column widths, in case the updated column + // is in the middle of a set of columns which don't yet have + // any explicit widths assigned (include updates to those not + // part of the adjacent blocks). + const nextColumnWidths = { + ...getColumnWidths( columns, columns.length ), + [ clientId ]: toWidthPrecision( width ), + ...getRedistributedColumnWidths( adjacentColumns, 100 - occupiedWidth, columns.length ), + }; + + forEach( nextColumnWidths, ( nextColumnWidth, columnClientId ) => { + updateBlockAttributes( columnClientId, { width: nextColumnWidth } ); + } ); + }, + }; + } ) +)( ColumnEdit ); diff --git a/gb-src/packages/block-library/src/column/icon.js b/gb-src/packages/block-library/src/column/icon.js new file mode 100644 index 0000000000000..080d796121d01 --- /dev/null +++ b/gb-src/packages/block-library/src/column/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16zm0-11.47L17.74 9 12 13.47 6.26 9 12 4.53z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/column/index.js b/gb-src/packages/block-library/src/column/index.js new file mode 100644 index 0000000000000..250ce0bad65a4 --- /dev/null +++ b/gb-src/packages/block-library/src/column/index.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Column' ), + parent: [ 'core/columns' ], + icon, + description: __( 'A single column within a columns block.' ), + supports: { + inserter: false, + reusable: false, + html: false, + }, + getEditWrapperProps( attributes ) { + const { width } = attributes; + if ( Number.isFinite( width ) ) { + return { + style: { + flexBasis: width + '%', + }, + }; + } + }, + edit, + save, +}; + diff --git a/gb-src/packages/block-library/src/column/save.js b/gb-src/packages/block-library/src/column/save.js new file mode 100644 index 0000000000000..d0dda9de3174b --- /dev/null +++ b/gb-src/packages/block-library/src/column/save.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { verticalAlignment, width } = attributes; + + const wrapperClasses = classnames( { + [ `is-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + } ); + + let style; + if ( Number.isFinite( width ) ) { + style = { flexBasis: width + '%' }; + } + + return ( + <div className={ wrapperClasses } style={ style }> + <InnerBlocks.Content /> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/columns/block.json b/gb-src/packages/block-library/src/columns/block.json new file mode 100644 index 0000000000000..3c22ca71fba62 --- /dev/null +++ b/gb-src/packages/block-library/src/columns/block.json @@ -0,0 +1,9 @@ +{ + "name": "core/columns", + "category": "layout", + "attributes": { + "verticalAlignment": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/columns/deprecated.js b/gb-src/packages/block-library/src/columns/deprecated.js new file mode 100644 index 0000000000000..56ffe5d90bc6a --- /dev/null +++ b/gb-src/packages/block-library/src/columns/deprecated.js @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { + InnerBlocks, +} from '@wordpress/block-editor'; + +/** + * Given an HTML string for a deprecated columns inner block, returns the + * column index to which the migrated inner block should be assigned. Returns + * undefined if the inner block was not assigned to a column. + * + * @param {string} originalContent Deprecated Columns inner block HTML. + * + * @return {?number} Column to which inner block is to be assigned. + */ +function getDeprecatedLayoutColumn( originalContent ) { + let { doc } = getDeprecatedLayoutColumn; + if ( ! doc ) { + doc = document.implementation.createHTMLDocument( '' ); + getDeprecatedLayoutColumn.doc = doc; + } + + let columnMatch; + + doc.body.innerHTML = originalContent; + for ( const classListItem of doc.body.firstChild.classList ) { + if ( ( columnMatch = classListItem.match( /^layout-column-(\d+)$/ ) ) ) { + return Number( columnMatch[ 1 ] ) - 1; + } + } +} + +export default [ + { + attributes: { + columns: { + type: 'number', + default: 2, + }, + }, + isEligible( attributes, innerBlocks ) { + // Since isEligible is called on every valid instance of the + // Columns block and a deprecation is the unlikely case due to + // its subsequent migration, optimize for the `false` condition + // by performing a naive, inaccurate pass at inner blocks. + const isFastPassEligible = innerBlocks.some( ( innerBlock ) => ( + /layout-column-\d+/.test( innerBlock.originalContent ) + ) ); + + if ( ! isFastPassEligible ) { + return false; + } + + // Only if the fast pass is considered eligible is the more + // accurate, durable, slower condition performed. + return innerBlocks.some( ( innerBlock ) => ( + getDeprecatedLayoutColumn( innerBlock.originalContent ) !== undefined + ) ); + }, + migrate( attributes, innerBlocks ) { + const columns = innerBlocks.reduce( ( result, innerBlock ) => { + const { originalContent } = innerBlock; + + let columnIndex = getDeprecatedLayoutColumn( originalContent ); + if ( columnIndex === undefined ) { + columnIndex = 0; + } + + if ( ! result[ columnIndex ] ) { + result[ columnIndex ] = []; + } + + result[ columnIndex ].push( innerBlock ); + + return result; + }, [] ); + + const migratedInnerBlocks = columns.map( ( columnBlocks ) => ( + createBlock( 'core/column', {}, columnBlocks ) + ) ); + + return [ + omit( attributes, [ 'columns' ] ), + migratedInnerBlocks, + ]; + }, + save( { attributes } ) { + const { columns } = attributes; + + return ( + <div className={ `has-${ columns }-columns` }> + <InnerBlocks.Content /> + </div> + ); + }, + }, + { + attributes: { + columns: { + type: 'number', + default: 2, + }, + }, + migrate( attributes, innerBlocks ) { + attributes = omit( attributes, [ 'columns' ] ); + + return [ attributes, innerBlocks ]; + }, + save( { attributes } ) { + const { verticalAlignment, columns } = attributes; + + const wrapperClasses = classnames( `has-${ columns }-columns`, { + [ `are-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + } ); + + return ( + <div className={ wrapperClasses }> + <InnerBlocks.Content /> + </div> + ); + }, + }, +]; diff --git a/gb-src/packages/block-library/src/columns/edit.js b/gb-src/packages/block-library/src/columns/edit.js new file mode 100644 index 0000000000000..48f35e90b2d9c --- /dev/null +++ b/gb-src/packages/block-library/src/columns/edit.js @@ -0,0 +1,267 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { dropRight, times } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + PanelBody, + RangeControl, + SVG, + Path, +} from '@wordpress/components'; +import { + InspectorControls, + InnerBlocks, + BlockControls, + BlockVerticalAlignmentToolbar, +} from '@wordpress/block-editor'; +import { withDispatch, useSelect } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + getColumnsTemplate, + hasExplicitColumnWidths, + getMappedColumnWidths, + getRedistributedColumnWidths, + toWidthPrecision, +} from './utils'; + +/** + * Allowed blocks constant is passed to InnerBlocks precisely as specified here. + * The contents of the array should never change. + * The array should contain the name of each block that is allowed. + * In columns block, the only block we allow is 'core/column'. + * + * @constant + * @type {string[]} + */ +const ALLOWED_BLOCKS = [ 'core/column' ]; + +/** + * Template option choices for predefined columns layouts. + * + * @constant + * @type {Array} + */ +const TEMPLATE_OPTIONS = [ + { + title: __( 'Two columns; equal split' ), + icon: <SVG width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" clipRule="evenodd" d="M39 12C40.1046 12 41 12.8954 41 14V34C41 35.1046 40.1046 36 39 36H9C7.89543 36 7 35.1046 7 34V14C7 12.8954 7.89543 12 9 12H39ZM39 34V14H25V34H39ZM23 34H9V14H23V34Z" /></SVG>, + template: [ + [ 'core/column' ], + [ 'core/column' ], + ], + }, + { + title: __( 'Two columns; one-third, two-thirds split' ), + icon: <SVG width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" clipRule="evenodd" d="M39 12C40.1046 12 41 12.8954 41 14V34C41 35.1046 40.1046 36 39 36H9C7.89543 36 7 35.1046 7 34V14C7 12.8954 7.89543 12 9 12H39ZM39 34V14H20V34H39ZM18 34H9V14H18V34Z" /></SVG>, + template: [ + [ 'core/column', { width: 33.33 } ], + [ 'core/column', { width: 66.66 } ], + ], + }, + { + title: __( 'Two columns; two-thirds, one-third split' ), + icon: <SVG width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" clipRule="evenodd" d="M39 12C40.1046 12 41 12.8954 41 14V34C41 35.1046 40.1046 36 39 36H9C7.89543 36 7 35.1046 7 34V14C7 12.8954 7.89543 12 9 12H39ZM39 34V14H30V34H39ZM28 34H9V14H28V34Z" /></SVG>, + template: [ + [ 'core/column', { width: 66.66 } ], + [ 'core/column', { width: 33.33 } ], + ], + }, + { + title: __( 'Three columns; equal split' ), + icon: <SVG width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" d="M41 14a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h30a2 2 0 0 0 2-2V14zM28.5 34h-9V14h9v20zm2 0V14H39v20h-8.5zm-13 0H9V14h8.5v20z" /></SVG>, + template: [ + [ 'core/column' ], + [ 'core/column' ], + [ 'core/column' ], + ], + }, + { + title: __( 'Three columns; wide center column' ), + icon: <SVG width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" d="M41 14a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h30a2 2 0 0 0 2-2V14zM31 34H17V14h14v20zm2 0V14h6v20h-6zm-18 0H9V14h6v20z" /></SVG>, + template: [ + [ 'core/column', { width: 25 } ], + [ 'core/column', { width: 50 } ], + [ 'core/column', { width: 25 } ], + ], + }, +]; + +/** + * Number of columns to assume for template in case the user opts to skip + * template option selection. + * + * @type {number} + */ +const DEFAULT_COLUMNS = 2; + +export function ColumnsEdit( { + attributes, + className, + updateAlignment, + updateColumns, + clientId, +} ) { + const { verticalAlignment } = attributes; + + const { count } = useSelect( ( select ) => { + return { + count: select( 'core/block-editor' ).getBlockCount( clientId ), + }; + } ); + const [ template, setTemplate ] = useState( getColumnsTemplate( count ) ); + const [ forceUseTemplate, setForceUseTemplate ] = useState( false ); + + // This is used to force the usage of the template even if the count doesn't match the template + // The count doesn't match the template once you use undo/redo (this is used to reset to the placeholder state). + useEffect( () => { + // Once the template is applied, reset it. + if ( forceUseTemplate ) { + setForceUseTemplate( false ); + } + }, [ forceUseTemplate ] ); + + const classes = classnames( className, { + [ `are-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + } ); + + // The template selector is shown when we first insert the columns block (count === 0). + // or if there's no template available. + // The count === 0 trick is useful when you use undo/redo. + const showTemplateSelector = ( count === 0 && ! forceUseTemplate ) || ! template; + + return ( + <> + { ! showTemplateSelector && ( + <> + <InspectorControls> + <PanelBody> + <RangeControl + label={ __( 'Columns' ) } + value={ count } + onChange={ ( value ) => updateColumns( count, value ) } + min={ 2 } + max={ 6 } + /> + </PanelBody> + </InspectorControls> + <BlockControls> + <BlockVerticalAlignmentToolbar + onChange={ updateAlignment } + value={ verticalAlignment } + /> + </BlockControls> + </> + ) } + <div className={ classes }> + <InnerBlocks + __experimentalTemplateOptions={ TEMPLATE_OPTIONS } + __experimentalOnSelectTemplateOption={ ( nextTemplate ) => { + if ( nextTemplate === undefined ) { + nextTemplate = getColumnsTemplate( DEFAULT_COLUMNS ); + } + + setTemplate( nextTemplate ); + setForceUseTemplate( true ); + } } + __experimentalAllowTemplateOptionSkip + template={ showTemplateSelector ? null : template } + templateLock="all" + allowedBlocks={ ALLOWED_BLOCKS } /> + </div> + </> + ); +} + +export default withDispatch( ( dispatch, ownProps, registry ) => ( { + /** + * Update all child Column blocks with a new vertical alignment setting + * based on whatever alignment is passed in. This allows change to parent + * to overide anything set on a individual column basis. + * + * @param {string} verticalAlignment the vertical alignment setting + */ + updateAlignment( verticalAlignment ) { + const { clientId, setAttributes } = ownProps; + const { updateBlockAttributes } = dispatch( 'core/block-editor' ); + const { getBlockOrder } = registry.select( 'core/block-editor' ); + + // Update own alignment. + setAttributes( { verticalAlignment } ); + + // Update all child Column Blocks to match + const innerBlockClientIds = getBlockOrder( clientId ); + innerBlockClientIds.forEach( ( innerBlockClientId ) => { + updateBlockAttributes( innerBlockClientId, { + verticalAlignment, + } ); + } ); + }, + + /** + * Updates the column count, including necessary revisions to child Column + * blocks to grant required or redistribute available space. + * + * @param {number} previousColumns Previous column count. + * @param {number} newColumns New column count. + */ + updateColumns( previousColumns, newColumns ) { + const { clientId } = ownProps; + const { replaceInnerBlocks } = dispatch( 'core/block-editor' ); + const { getBlocks } = registry.select( 'core/block-editor' ); + + let innerBlocks = getBlocks( clientId ); + const hasExplicitWidths = hasExplicitColumnWidths( innerBlocks ); + + // Redistribute available width for existing inner blocks. + const isAddingColumn = newColumns > previousColumns; + + if ( isAddingColumn && hasExplicitWidths ) { + // If adding a new column, assign width to the new column equal to + // as if it were `1 / columns` of the total available space. + const newColumnWidth = toWidthPrecision( 100 / newColumns ); + + // Redistribute in consideration of pending block insertion as + // constraining the available working width. + const widths = getRedistributedColumnWidths( innerBlocks, 100 - newColumnWidth ); + + innerBlocks = [ + ...getMappedColumnWidths( innerBlocks, widths ), + ...times( newColumns - previousColumns, () => { + return createBlock( 'core/column', { + width: newColumnWidth, + } ); + } ), + ]; + } else if ( isAddingColumn ) { + innerBlocks = [ + ...innerBlocks, + ...times( newColumns - previousColumns, () => { + return createBlock( 'core/column' ); + } ), + ]; + } else { + // The removed column will be the last of the inner blocks. + innerBlocks = dropRight( innerBlocks, previousColumns - newColumns ); + + if ( hasExplicitWidths ) { + // Redistribute as if block is already removed. + const widths = getRedistributedColumnWidths( innerBlocks, 100 ); + + innerBlocks = getMappedColumnWidths( innerBlocks, widths ); + } + } + + replaceInnerBlocks( clientId, innerBlocks, false ); + }, +} ) )( ColumnsEdit ); diff --git a/gb-src/packages/block-library/src/columns/editor.scss b/gb-src/packages/block-library/src/columns/editor.scss new file mode 100644 index 0000000000000..2045a1e12e965 --- /dev/null +++ b/gb-src/packages/block-library/src/columns/editor.scss @@ -0,0 +1,202 @@ +// These margins make sure that nested blocks stack/overlay with the parent block chrome +// This is sort of an experiment at making sure the editor looks as much like the end result as possible +// Potentially the rules here can apply to all nested blocks and enable stacking, in which case it should be moved elsewhere +// When using CSS grid, margins do not collapse on the container. +.wp-block-columns .editor-block-list__layout { + margin-left: 0; + margin-right: 0; + + // This max-width is used to constrain the main editor column, it should not cascade into columns + .editor-block-list__block { + max-width: none; + } +} + +// Fullwide: show margin left/right to ensure there's room for the side UI. +// This is not a 1:1 preview with the front-end where these margins would presumably be zero. +[data-type="core/columns"][data-align="full"] .wp-block-columns > .editor-inner-blocks { + padding-left: $block-padding; + padding-right: $block-padding; + + @include break-small() { + padding-left: $block-container-side-padding; + padding-right: $block-container-side-padding; + } +} + +.wp-block-columns { + display: block; + + > .editor-inner-blocks > .editor-block-list__layout { + display: flex; + + // Responsiveness: Allow wrapping on mobile. + flex-wrap: wrap; + + @include break-medium() { + flex-wrap: nowrap; + } + // Set full heights on Columns to enable vertical alignment preview + > [data-type="core/column"], + > [data-type="core/column"] > .editor-block-list__block-edit, + > [data-type="core/column"] > .editor-block-list__block-edit > div[data-block], + > [data-type="core/column"] > .editor-block-list__block-edit .block-core-columns { + display: flex; + flex-direction: column; + + // This flex rule fixes an issue in IE11. + flex: 1 1 auto; + + // IE11 does not support `position: sticky`, so we use it here to serve correct Flex rules to modern browsers. + @supports (position: sticky) { + flex: 1; + } + } + + // Adjust the individual column block. + > [data-type="core/column"] { + + // On mobile, only a single column is shown, so match adjacent block paddings. + padding-left: 0; + padding-right: 0; + margin-left: -$block-padding; + margin-right: -$block-padding; + + // Prevent the columns from growing wider than their distributed sizes. + min-width: 0; + + // Prevent long unbroken words from overflowing. + word-break: break-word; // For back-compat. + overflow-wrap: break-word; // New standard. + + // Responsiveness: Show at most one columns on mobile. + flex-basis: 100%; + + // Beyond mobile, allow 2 columns. + @include break-small() { + flex-basis: calc(50% - (#{$grid-size-large} + #{$block-padding * 2})); + flex-grow: 0; + margin-left: $block-padding; + margin-right: $block-padding; + } + + // Add space between columns. Themes can customize this if they wish to work differently. + // This has to match the same padding applied in style.scss. + // Only apply this beyond the mobile breakpoint, as there's only a single column on mobile. + @include break-small() { + &:nth-child(even) { + margin-left: calc(#{$grid-size-large * 2} + #{$block-padding}); + } + } + + // When columns are in a single row, add space before all except the first. + @include break-medium() { + &:not(:first-child) { + margin-left: calc(#{$grid-size-large * 2} + #{$block-padding}); + } + } + + > .editor-block-list__block-edit { + margin-top: 0; + margin-bottom: 0; + + // Remove Block "padding" so individual Column is flush with parent Columns + &::before { + left: 0; + right: 0; + } + + > .editor-block-contextual-toolbar { + margin-left: -$border-width; + } + + // Zero out margins. + > [data-block] { + margin-top: 0; + margin-bottom: 0; + } + + // The Columns block is a flex-container, therefore it nullifies margin collapsing. + // Therefore, blocks inside this will appear to create a double margin. + // We compensate for this using negative margins. + > div > .block-core-columns > .editor-inner-blocks { + margin-top: -$default-block-margin; + margin-bottom: -$default-block-margin; + } + } + } + } +} + +/** + * Columns act as as a "passthrough container" + * and therefore has its vertical margins/padding removed via negative margins + * therefore we need to compensate for this here by doubling the spacing on the + * vertical to ensure there is equal visual spacing around the inserter. Note there + * is no formal API for a "passthrough" Block so this is an edge case overide + */ +[data-type="core/columns"] { + + .block-list-appender { + margin-top: $block-padding*2; + margin-bottom: $block-padding*2; + } + + // When the individual column block is selected, the nested padding overrules + // some of this margin. We need to adjust the appender spacing again as a result. + [data-type="core/column"].is-selected .block-list-appender { + margin: $block-padding 0; + } +} + +/** + * Vertical Alignment Preview + * note: specificity is important here to ensure individual + * * columns alignment is prioritised over parent column alignment + * + */ +.are-vertically-aligned-top .block-core-columns, +div.block-core-columns.is-vertically-aligned-top { + justify-content: flex-start; +} + +.are-vertically-aligned-center .block-core-columns, +div.block-core-columns.is-vertically-aligned-center { + justify-content: center; +} + +.are-vertically-aligned-bottom .block-core-columns, +div.block-core-columns.is-vertically-aligned-bottom { + justify-content: flex-end; +} + +/** + * Fixes single Column breadcrumb position. + */ +[data-type="core/column"] > .editor-block-list__block-edit > .editor-block-list__breadcrumb { + left: -$block-left-border-width; +} + +/** + * Make single Column overlay not extend past boundaries of parent + */ +.block-core-columns > .block-editor-inner-blocks.has-overlay::after { + left: 0; + right: 0; +} + +/** + * Add extra padding when the parent block is selected, for easier interaction. + */ +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/columns"].is-selected > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks, +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/columns"].has-child-selected > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks, +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/column"].is-selected > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks, +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/column"].has-child-selected > .block-editor-block-list__block-edit > [data-block] > div > .block-editor-inner-blocks { + padding: $block-padding; + + // Negate this padding for the placeholder. + > .components-placeholder { + margin: -$block-padding; + width: calc(100% + #{$block-padding * 2}); + } +} diff --git a/gb-src/packages/block-library/src/columns/icon.js b/gb-src/packages/block-library/src/columns/icon.js new file mode 100644 index 0000000000000..f45aba71242c4 --- /dev/null +++ b/gb-src/packages/block-library/src/columns/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M4,4H20a2,2,0,0,1,2,2V18a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V6A2,2,0,0,1,4,4ZM4 6V18H8V6Zm6 0V18h4V6Zm6 0V18h4V6Z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/columns/index.js b/gb-src/packages/block-library/src/columns/index.js new file mode 100644 index 0000000000000..990d94d45521c --- /dev/null +++ b/gb-src/packages/block-library/src/columns/index.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Columns' ), + icon, + description: __( 'Add a block that displays content in multiple columns, then add whatever content blocks you’d like.' ), + supports: { + align: [ 'wide', 'full' ], + html: false, + }, + example: { + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent et eros eu felis.', + }, + }, + { + name: 'core/image', + attributes: { + url: 'https://s.w.org/images/core/5.3/Windbuchencom.jpg', + }, + }, + { + name: 'core/paragraph', + attributes: { + content: 'Suspendisse commodo neque lacus, a dictum orci interdum et.', + }, + }, + ], + }, + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: __( 'Etiam et egestas lorem. Vivamus sagittis sit amet dolor quis lobortis. Integer sed fermentum arcu, id vulputate lacus. Etiam fermentum sem eu quam hendrerit.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + content: __( 'Nam risus massa, ullamcorper consectetur eros fermentum, porta aliquet ligula. Sed vel mauris nec enim.' ), + }, + }, + ], + }, + ], + }, + deprecated, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/columns/save.js b/gb-src/packages/block-library/src/columns/save.js new file mode 100644 index 0000000000000..8f9b8c2d67362 --- /dev/null +++ b/gb-src/packages/block-library/src/columns/save.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { verticalAlignment } = attributes; + + const wrapperClasses = classnames( { + [ `are-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + } ); + + return ( + <div className={ wrapperClasses }> + <InnerBlocks.Content /> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/columns/style.scss b/gb-src/packages/block-library/src/columns/style.scss new file mode 100644 index 0000000000000..0c641d5060baa --- /dev/null +++ b/gb-src/packages/block-library/src/columns/style.scss @@ -0,0 +1,83 @@ +.wp-block-columns { + display: flex; + margin-bottom: $default-block-margin; + + // Responsiveness: Allow wrapping on mobile. + flex-wrap: wrap; + + @include break-medium() { + flex-wrap: nowrap; + } +} + +.wp-block-column { + flex-grow: 1; + + @media (max-width: #{ ($break-small - 1) }) { + // Responsiveness: Show at most one columns on mobile. This must be + // important since the Column assigns its own width as an inline style. + flex-basis: 100% !important; + } + + // Prevent the columns from growing wider than their distributed sizes. + min-width: 0; + + // Prevent long unbroken words from overflowing. + word-break: break-word; // For back-compat. + overflow-wrap: break-word; // New standard. + + @include break-small() { + + // Beyond mobile, allow 2 columns. + flex-basis: calc(50% - #{$grid-size-large}); + flex-grow: 0; + + // Add space between the multiple columns. Themes can customize this if they wish to work differently. + // Only apply this beyond the mobile breakpoint, as there's only a single column on mobile. + &:nth-child(even) { + margin-left: $grid-size-large * 2; + } + } + + @include break-medium() { + + // When columns are in a single row, add space before all except the first. + &:not(:first-child) { + margin-left: $grid-size-large * 2; + } + } +} + +/** + * All Columns Alignment + */ +.wp-block-columns { + &.are-vertically-aligned-top { + align-items: flex-start; + } + + &.are-vertically-aligned-center { + align-items: center; + } + + &.are-vertically-aligned-bottom { + align-items: flex-end; + } +} + +/** + * Individual Column Alignment + */ +.wp-block-column { + &.is-vertically-aligned-top { + align-self: flex-start; + } + + &.is-vertically-aligned-center { + align-self: center; + } + + &.is-vertically-aligned-bottom { + align-self: flex-end; + } +} diff --git a/gb-src/packages/block-library/src/columns/test/utils.js b/gb-src/packages/block-library/src/columns/test/utils.js new file mode 100644 index 0000000000000..b89a63e2e0153 --- /dev/null +++ b/gb-src/packages/block-library/src/columns/test/utils.js @@ -0,0 +1,235 @@ +/** + * Internal dependencies + */ +import { + getColumnsTemplate, + toWidthPrecision, + getAdjacentBlocks, + getEffectiveColumnWidth, + getTotalColumnsWidth, + getColumnWidths, + getRedistributedColumnWidths, + hasExplicitColumnWidths, + getMappedColumnWidths, +} from '../utils'; + +describe( 'getColumnsTemplate', () => { + it( 'should return a template corresponding to columns count', () => { + const template = getColumnsTemplate( 4 ); + + expect( template ).toEqual( [ + [ 'core/column' ], + [ 'core/column' ], + [ 'core/column' ], + [ 'core/column' ], + ] ); + } ); + + it( 'should return null if columns count is not defined', () => { + const template = getColumnsTemplate( undefined ); + + expect( template ).toBe( null ); + } ); +} ); + +describe( 'toWidthPrecision', () => { + it( 'should round value to standard precision', () => { + const value = toWidthPrecision( 50.108 ); + + expect( value ).toBe( 50.11 ); + } ); + + it( 'should return undefined for invalid number', () => { + expect( toWidthPrecision( null ) ).toBe( undefined ); + expect( toWidthPrecision( undefined ) ).toBe( undefined ); + } ); +} ); + +describe( 'getAdjacentBlocks', () => { + const blockA = { clientId: 'a' }; + const blockB = { clientId: 'b' }; + const blockC = { clientId: 'c' }; + const blocks = [ blockA, blockB, blockC ]; + + it( 'should return blocks after clientId', () => { + const result = getAdjacentBlocks( blocks, 'b' ); + + expect( result ).toEqual( [ blockC ] ); + } ); + + it( 'should return blocks before clientId if clientId is last', () => { + const result = getAdjacentBlocks( blocks, 'c' ); + + expect( result ).toEqual( [ blockA, blockB ] ); + } ); +} ); + +describe( 'getEffectiveColumnWidth', () => { + it( 'should return attribute value if set, rounded to precision', () => { + const block = { attributes: { width: 50.108 } }; + + const width = getEffectiveColumnWidth( block, 3 ); + + expect( width ).toBe( 50.11 ); + } ); + + it( 'should return assumed width if attribute value not set, rounded to precision', () => { + const block = { attributes: {} }; + + const width = getEffectiveColumnWidth( block, 3 ); + + expect( width ).toBe( 33.33 ); + } ); +} ); + +describe( 'getTotalColumnsWidth', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + + it( 'returns the sum total of columns width', () => { + const width = getTotalColumnsWidth( blocks ); + + expect( width ).toBe( 70 ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'returns the sum total of columns width', () => { + const widths = getTotalColumnsWidth( blocks ); + + expect( widths ).toBe( 100 ); + } ); + } ); +} ); + +describe( 'getColumnWidths', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30.459 } }, + { clientId: 'b', attributes: { width: 29.543 } }, + ]; + + it( 'returns the column widths', () => { + const widths = getColumnWidths( blocks ); + + expect( widths ).toEqual( { + a: 30.46, + b: 29.54, + } ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'returns the column widths', () => { + const widths = getColumnWidths( blocks ); + + expect( widths ).toEqual( { + a: 50, + b: 50, + } ); + } ); + } ); +} ); + +describe( 'getRedistributedColumnWidths', () => { + describe( 'explicit width', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + + it( 'should constrain to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 60 ); + + expect( widths ).toEqual( { + a: 25, + b: 35, + } ); + } ); + + it( 'should expand to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 80 ); + + expect( widths ).toEqual( { + a: 35, + b: 45, + } ); + } ); + } ); + + describe( 'implicit width', () => { + const blocks = [ + { clientId: 'a', attributes: {} }, + { clientId: 'b', attributes: {} }, + ]; + + it( 'should equally distribute to available width', () => { + const widths = getRedistributedColumnWidths( blocks, 60 ); + + expect( widths ).toEqual( { + a: 30, + b: 30, + } ); + } ); + + it( 'should constrain to fit available width', () => { + const widths = getRedistributedColumnWidths( blocks, 66.66, 3 ); + + expect( widths ).toEqual( { + a: 33.33, + b: 33.33, + } ); + } ); + } ); +} ); + +describe( 'hasExplicitColumnWidths', () => { + it( 'returns false if no blocks have explicit width', () => { + const blocks = [ { attributes: {} } ]; + + const result = hasExplicitColumnWidths( blocks ); + + expect( result ).toBe( false ); + } ); + + it( 'returns true if a block has explicit width', () => { + const blocks = [ { attributes: { width: 10 } } ]; + + const result = hasExplicitColumnWidths( blocks ); + + expect( result ).toBe( true ); + } ); +} ); + +describe( 'getMappedColumnWidths', () => { + it( 'merges to block attributes using provided widths', () => { + const blocks = [ + { clientId: 'a', attributes: { width: 30 } }, + { clientId: 'b', attributes: { width: 40 } }, + ]; + const widths = { + a: 25, + b: 35, + }; + + const result = getMappedColumnWidths( blocks, widths ); + + expect( result ).toEqual( [ + { clientId: 'a', attributes: { width: 25 } }, + { clientId: 'b', attributes: { width: 35 } }, + ] ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/columns/utils.js b/gb-src/packages/block-library/src/columns/utils.js new file mode 100644 index 0000000000000..77a0b7cf4375a --- /dev/null +++ b/gb-src/packages/block-library/src/columns/utils.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import memoize from 'memize'; +import { times, findIndex, sumBy, merge, mapValues } from 'lodash'; + +/** + * Returns the layouts configuration for a given number of columns. + * + * @param {number} columns Number of columns. + * + * @return {Object[]} Columns layout configuration. + */ +export const getColumnsTemplate = memoize( ( columns ) => { + if ( columns === undefined ) { + return null; + } + + return times( columns, () => [ 'core/column' ] ); +} ); + +/** + * Returns a column width attribute value rounded to standard precision. + * Returns `undefined` if the value is not a valid finite number. + * + * @param {?number} value Raw value. + * + * @return {number} Value rounded to standard precision. + */ +export const toWidthPrecision = ( value ) => + Number.isFinite( value ) ? + parseFloat( value.toFixed( 2 ) ) : + undefined; + +/** + * Returns the considered adjacent to that of the specified `clientId` for + * resizing consideration. Adjacent blocks are those occurring after, except + * when the given block is the last block in the set. For the last block, the + * behavior is reversed. + * + * @param {WPBlock[]} blocks Block objects. + * @param {string} clientId Client ID to consider for adjacent blocks. + * + * @return {WPBlock[]} Adjacent block objects. + */ +export function getAdjacentBlocks( blocks, clientId ) { + const index = findIndex( blocks, { clientId } ); + const isLastBlock = index === blocks.length - 1; + + return isLastBlock ? blocks.slice( 0, index ) : blocks.slice( index + 1 ); +} + +/** + * Returns an effective width for a given block. An effective width is equal to + * its attribute value if set, or a computed value assuming equal distribution. + * + * @param {WPBlock} block Block object. + * @param {number} totalBlockCount Total number of blocks in Columns. + * + * @return {number} Effective column width. + */ +export function getEffectiveColumnWidth( block, totalBlockCount ) { + const { width = 100 / totalBlockCount } = block.attributes; + return toWidthPrecision( width ); +} + +/** + * Returns the total width occupied by the given set of column blocks. + * + * @param {WPBlock[]} blocks Block objects. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {number} Total width occupied by blocks. + */ +export function getTotalColumnsWidth( blocks, totalBlockCount = blocks.length ) { + return sumBy( blocks, ( block ) => getEffectiveColumnWidth( block, totalBlockCount ) ); +} + +/** + * Returns an object of `clientId` → `width` of effective column widths. + * + * @param {WPBlock[]} blocks Block objects. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {Object<string,number>} Column widths. + */ +export function getColumnWidths( blocks, totalBlockCount = blocks.length ) { + return blocks.reduce( ( result, block ) => { + const width = getEffectiveColumnWidth( block, totalBlockCount ); + return Object.assign( result, { [ block.clientId ]: width } ); + }, {} ); +} + +/** + * Returns an object of `clientId` → `width` of column widths as redistributed + * proportional to their current widths, constrained or expanded to fit within + * the given available width. + * + * @param {WPBlock[]} blocks Block objects. + * @param {number} availableWidth Maximum width to fit within. + * @param {?number} totalBlockCount Total number of blocks in Columns. + * Defaults to number of blocks passed. + * + * @return {Object<string,number>} Redistributed column widths. + */ +export function getRedistributedColumnWidths( blocks, availableWidth, totalBlockCount = blocks.length ) { + const totalWidth = getTotalColumnsWidth( blocks, totalBlockCount ); + const difference = availableWidth - totalWidth; + const adjustment = difference / blocks.length; + + return mapValues( + getColumnWidths( blocks, totalBlockCount ), + ( width ) => toWidthPrecision( width + adjustment ), + ); +} + +/** + * Returns true if column blocks within the provided set are assigned with + * explicit widths, or false otherwise. + * + * @param {WPBlock[]} blocks Block objects. + * + * @return {boolean} Whether columns have explicit widths. + */ +export function hasExplicitColumnWidths( blocks ) { + return blocks.some( ( block ) => Number.isFinite( block.attributes.width ) ); +} + +/** + * Returns a copy of the given set of blocks with new widths assigned from the + * provided object of redistributed column widths. + * + * @param {WPBlock[]} blocks Block objects. + * @param {Object<string,number>} widths Redistributed column widths. + * + * @return {WPBlock[]} blocks Mapped block objects. + */ +export function getMappedColumnWidths( blocks, widths ) { + return blocks.map( ( block ) => merge( {}, block, { + attributes: { + width: widths[ block.clientId ], + }, + } ) ); +} diff --git a/gb-src/packages/block-library/src/cover/block.json b/gb-src/packages/block-library/src/cover/block.json new file mode 100644 index 0000000000000..957a8264ef6e6 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/block.json @@ -0,0 +1,36 @@ +{ + "name": "core/cover", + "category": "common", + "attributes": { + "url": { + "type": "string" + }, + "id": { + "type": "number" + }, + "hasParallax": { + "type": "boolean", + "default": false + }, + "dimRatio": { + "type": "number", + "default": 50 + }, + "overlayColor": { + "type": "string" + }, + "customOverlayColor": { + "type": "string" + }, + "backgroundType": { + "type": "string", + "default": "image" + }, + "focalPoint": { + "type": "object" + }, + "minHeight": { + "type": "number" + } + } +} diff --git a/gb-src/packages/block-library/src/cover/deprecated.js b/gb-src/packages/block-library/src/cover/deprecated.js new file mode 100644 index 0000000000000..ef1a854918af1 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/deprecated.js @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { + RichText, + getColorClassName, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + IMAGE_BACKGROUND_TYPE, + VIDEO_BACKGROUND_TYPE, + backgroundImageStyles, + dimRatioToClass, +} from './shared'; + +const blockAttributes = { + url: { + type: 'string', + }, + id: { + type: 'number', + }, + hasParallax: { + type: 'boolean', + default: false, + }, + dimRatio: { + type: 'number', + default: 50, + }, + overlayColor: { + type: 'string', + }, + customOverlayColor: { + type: 'string', + }, + backgroundType: { + type: 'string', + default: 'image', + }, + focalPoint: { + type: 'object', + }, +}; + +const deprecated = [ + { + attributes: { + ...blockAttributes, + title: { + type: 'string', + source: 'html', + selector: 'p', + }, + contentAlign: { + type: 'string', + default: 'center', + }, + }, + supports: { + align: true, + }, + save( { attributes } ) { + const { + backgroundType, + contentAlign, + customOverlayColor, + dimRatio, + focalPoint, + hasParallax, + overlayColor, + title, + url, + } = attributes; + const overlayColorClass = getColorClassName( 'background-color', overlayColor ); + const style = backgroundType === IMAGE_BACKGROUND_TYPE ? + backgroundImageStyles( url ) : + {}; + if ( ! overlayColorClass ) { + style.backgroundColor = customOverlayColor; + } + if ( focalPoint && ! hasParallax ) { + style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`; + } + + const classes = classnames( + dimRatioToClass( dimRatio ), + overlayColorClass, + { + 'has-background-dim': dimRatio !== 0, + 'has-parallax': hasParallax, + [ `has-${ contentAlign }-content` ]: contentAlign !== 'center', + }, + ); + + return ( + <div className={ classes } style={ style }> + { VIDEO_BACKGROUND_TYPE === backgroundType && url && ( <video + className="wp-block-cover__video-background" + autoPlay + muted + loop + src={ url } + /> ) } + { ! RichText.isEmpty( title ) && ( + <RichText.Content tagName="p" className="wp-block-cover-text" value={ title } /> + ) } + </div> + ); + }, + migrate( attributes ) { + return [ + omit( attributes, [ 'title', 'contentAlign' ] ), + [ + createBlock( + 'core/paragraph', + { + content: attributes.title, + align: attributes.contentAlign, + fontSize: 'large', + placeholder: __( 'Write title…' ), + } + ), + ], + ]; + }, + }, { + attributes: { + ...blockAttributes, + title: { + type: 'string', + source: 'html', + selector: 'p', + }, + contentAlign: { + type: 'string', + default: 'center', + }, + align: { + type: 'string', + }, + }, + supports: { + className: false, + }, + save( { attributes } ) { + const { url, title, hasParallax, dimRatio, align, contentAlign, overlayColor, customOverlayColor } = attributes; + const overlayColorClass = getColorClassName( 'background-color', overlayColor ); + const style = backgroundImageStyles( url ); + if ( ! overlayColorClass ) { + style.backgroundColor = customOverlayColor; + } + + const classes = classnames( + 'wp-block-cover-image', + dimRatioToClass( dimRatio ), + overlayColorClass, + { + 'has-background-dim': dimRatio !== 0, + 'has-parallax': hasParallax, + [ `has-${ contentAlign }-content` ]: contentAlign !== 'center', + }, + align ? `align${ align }` : null, + ); + + return ( + <div className={ classes } style={ style }> + { ! RichText.isEmpty( title ) && ( + <RichText.Content tagName="p" className="wp-block-cover-image-text" value={ title } /> + ) } + </div> + ); + }, + migrate( attributes ) { + return [ + omit( attributes, [ 'title', 'contentAlign', 'align' ] ), + [ + createBlock( + 'core/paragraph', + { + content: attributes.title, + align: attributes.contentAlign, + fontSize: 'large', + placeholder: __( 'Write title…' ), + } + ), + ], + ]; + }, + }, { + attributes: { + ...blockAttributes, + title: { + type: 'string', + source: 'html', + selector: 'h2', + }, + align: { + type: 'string', + }, + contentAlign: { + type: 'string', + default: 'center', + }, + }, + supports: { + className: false, + }, + save( { attributes } ) { + const { url, title, hasParallax, dimRatio, align } = attributes; + const style = backgroundImageStyles( url ); + const classes = classnames( + 'wp-block-cover-image', + dimRatioToClass( dimRatio ), + { + 'has-background-dim': dimRatio !== 0, + 'has-parallax': hasParallax, + }, + align ? `align${ align }` : null, + ); + + return ( + <section className={ classes } style={ style }> + <RichText.Content tagName="h2" value={ title } /> + </section> + ); + }, + migrate( attributes ) { + return [ + omit( attributes, [ 'title', 'contentAlign', 'align' ] ), + [ + createBlock( + 'core/paragraph', + { + content: attributes.title, + align: attributes.contentAlign, + fontSize: 'large', + placeholder: __( 'Write title…' ), + } + ), + ], + ]; + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/cover/edit.js b/gb-src/packages/block-library/src/cover/edit.js new file mode 100644 index 0000000000000..1994f4a57e071 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/edit.js @@ -0,0 +1,575 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import FastAverageColor from 'fast-average-color'; +import tinycolor from 'tinycolor2'; + +/** + * WordPress dependencies + */ +import { + Component, + createRef, + useCallback, + useState, +} from '@wordpress/element'; +import { + FocalPointPicker, + IconButton, + PanelBody, + PanelRow, + RangeControl, + ToggleControl, + Toolbar, + withNotices, + ResizableBox, + BaseControl, + Button, +} from '@wordpress/components'; +import { compose, withInstanceId } from '@wordpress/compose'; +import { + BlockControls, + BlockIcon, + InnerBlocks, + InspectorControls, + MediaPlaceholder, + MediaUpload, + MediaUploadCheck, + PanelColorSettings, + withColors, + ColorPalette, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import icon from './icon'; +import { + IMAGE_BACKGROUND_TYPE, + VIDEO_BACKGROUND_TYPE, + COVER_MIN_HEIGHT, + backgroundImageStyles, + dimRatioToClass, +} from './shared'; + +/** + * Module Constants + */ +const ALLOWED_MEDIA_TYPES = [ 'image', 'video' ]; +const INNER_BLOCKS_TEMPLATE = [ + [ 'core/paragraph', { + align: 'center', + fontSize: 'large', + placeholder: __( 'Write title…' ), + } ], +]; + +function retrieveFastAverageColor() { + if ( ! retrieveFastAverageColor.fastAverageColor ) { + retrieveFastAverageColor.fastAverageColor = new FastAverageColor(); + } + return retrieveFastAverageColor.fastAverageColor; +} + +const CoverHeightInput = withInstanceId( + function( { value = '', instanceId, onChange } ) { + const [ temporaryInput, setTemporaryInput ] = useState( null ); + const onChangeEvent = useCallback( + ( event ) => { + const unprocessedValue = event.target.value; + const inputValue = unprocessedValue !== '' ? + parseInt( event.target.value, 10 ) : + undefined; + if ( ( isNaN( inputValue ) || inputValue < COVER_MIN_HEIGHT ) && inputValue !== undefined ) { + setTemporaryInput( event.target.value ); + return; + } + setTemporaryInput( null ); + onChange( inputValue ); + }, + [ onChange, setTemporaryInput ] + ); + const onBlurEvent = useCallback( + () => { + if ( temporaryInput !== null ) { + setTemporaryInput( null ); + } + }, + [ temporaryInput, setTemporaryInput ] + ); + const inputId = `block-cover-height-input-${ instanceId }`; + return ( + <BaseControl label={ __( 'Minimum height in pixels' ) } id={ inputId }> + <input + type="number" + id={ inputId } + onChange={ onChangeEvent } + onBlur={ onBlurEvent } + value={ temporaryInput !== null ? temporaryInput : value } + min={ COVER_MIN_HEIGHT } + step="10" + /> + </BaseControl> + ); + } +); + +const RESIZABLE_BOX_ENABLE_OPTION = { + top: false, + right: false, + bottom: true, + left: false, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, +}; + +function ResizableCover( { + className, + children, + onResizeStart, + onResize, + onResizeStop, +} ) { + const [ isResizing, setIsResizing ] = useState( false ); + const onResizeEvent = useCallback( + ( event, direction, elt ) => { + onResize( elt.clientHeight ); + if ( ! isResizing ) { + setIsResizing( true ); + } + }, + [ onResize, setIsResizing ], + ); + const onResizeStartEvent = useCallback( + ( event, direction, elt ) => { + onResizeStart( elt.clientHeight ); + onResize( elt.clientHeight ); + }, + [ onResizeStart, onResize ] + ); + const onResizeStopEvent = useCallback( + ( event, direction, elt ) => { + onResizeStop( elt.clientHeight ); + setIsResizing( false ); + }, + [ onResizeStop, setIsResizing ] + ); + + return ( + <ResizableBox + className={ classnames( + className, + { + 'is-resizing': isResizing, + } + ) } + enable={ RESIZABLE_BOX_ENABLE_OPTION } + onResizeStart={ onResizeStartEvent } + onResize={ onResizeEvent } + onResizeStop={ onResizeStopEvent } + minHeight={ COVER_MIN_HEIGHT } + > + { children } + </ResizableBox> + ); +} + +class CoverEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + isDark: false, + temporaryMinHeight: null, + }; + this.imageRef = createRef(); + this.videoRef = createRef(); + this.changeIsDarkIfRequired = this.changeIsDarkIfRequired.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + } + + componentDidMount() { + this.handleBackgroundMode(); + } + + componentDidUpdate( prevProps ) { + this.handleBackgroundMode( prevProps ); + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + render() { + const { + attributes, + setAttributes, + isSelected, + className, + noticeUI, + overlayColor, + setOverlayColor, + toggleSelection, + } = this.props; + const { + backgroundType, + dimRatio, + focalPoint, + hasParallax, + id, + url, + minHeight, + } = attributes; + const onSelectMedia = ( media ) => { + if ( ! media || ! media.url ) { + setAttributes( { url: undefined, id: undefined } ); + return; + } + let mediaType; + // for media selections originated from a file upload. + if ( media.media_type ) { + if ( media.media_type === IMAGE_BACKGROUND_TYPE ) { + mediaType = IMAGE_BACKGROUND_TYPE; + } else { + // only images and videos are accepted so if the media_type is not an image we can assume it is a video. + // Videos contain the media type of 'file' in the object returned from the rest api. + mediaType = VIDEO_BACKGROUND_TYPE; + } + } else { // for media selections originated from existing files in the media library. + if ( + media.type !== IMAGE_BACKGROUND_TYPE && + media.type !== VIDEO_BACKGROUND_TYPE + ) { + return; + } + mediaType = media.type; + } + + setAttributes( { + url: media.url, + id: media.id, + backgroundType: mediaType, + ...( mediaType === VIDEO_BACKGROUND_TYPE ? + { focalPoint: undefined, hasParallax: undefined } : + {} + ), + } ); + }; + + const toggleParallax = () => { + setAttributes( { + hasParallax: ! hasParallax, + ...( ! hasParallax ? { focalPoint: undefined } : {} ), + } ); + }; + const setDimRatio = ( ratio ) => setAttributes( { dimRatio: ratio } ); + + const { temporaryMinHeight } = this.state; + + const style = { + ...( + backgroundType === IMAGE_BACKGROUND_TYPE ? + backgroundImageStyles( url ) : + {} + ), + backgroundColor: overlayColor.color, + minHeight: ( temporaryMinHeight || minHeight ), + }; + + if ( focalPoint ) { + style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`; + } + + const controls = ( + <> + <BlockControls> + { !! ( url || overlayColor.color ) && ( + <> + <MediaUploadCheck> + <Toolbar> + <MediaUpload + onSelect={ onSelectMedia } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ id } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit media' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + </MediaUploadCheck> + </> + ) } + </BlockControls> + <InspectorControls> + { !! url && ( + <PanelBody title={ __( 'Media Settings' ) }> + { IMAGE_BACKGROUND_TYPE === backgroundType && ( + <ToggleControl + label={ __( 'Fixed Background' ) } + checked={ hasParallax } + onChange={ toggleParallax } + /> + ) } + { IMAGE_BACKGROUND_TYPE === backgroundType && ! hasParallax && ( + <FocalPointPicker + label={ __( 'Focal Point Picker' ) } + url={ url } + value={ focalPoint } + onChange={ ( value ) => setAttributes( { focalPoint: value } ) } + /> + ) } + <PanelRow> + <Button + isDefault + isSmall + className="block-library-cover__reset-button" + onClick={ () => setAttributes( { + url: undefined, + id: undefined, + backgroundType: undefined, + dimRatio: undefined, + focalPoint: undefined, + hasParallax: undefined, + } ) } + > + { __( 'Clear Media' ) } + </Button> + </PanelRow> + </PanelBody> + ) } + { ( url || overlayColor.color ) && ( + <> + <PanelBody title={ __( 'Dimensions' ) }> + <CoverHeightInput + value={ temporaryMinHeight || minHeight } + onChange={ + ( value ) => { + setAttributes( { + minHeight: value, + } ); + } + } + /> + </PanelBody> + <PanelColorSettings + title={ __( 'Overlay' ) } + initialOpen={ true } + colorSettings={ [ { + value: overlayColor.color, + onChange: setOverlayColor, + label: __( 'Overlay Color' ), + } ] } + > + { !! url && ( + <RangeControl + label={ __( 'Background Opacity' ) } + value={ dimRatio } + onChange={ setDimRatio } + min={ 0 } + max={ 100 } + step={ 10 } + required + /> + ) } + </PanelColorSettings> + </> + ) } + </InspectorControls> + </> + ); + + if ( ! ( url || overlayColor.color ) ) { + const placeholderIcon = <BlockIcon icon={ icon } />; + const label = __( 'Cover' ); + + return ( + <> + { controls } + <MediaPlaceholder + icon={ placeholderIcon } + className={ className } + labels={ { + title: label, + instructions: __( 'Upload an image or video file, or pick one from your media library.' ), + } } + onSelect={ onSelectMedia } + accept="image/*,video/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + notices={ noticeUI } + onError={ this.onUploadError } + > + <ColorPalette + disableCustomColors={ true } + value={ overlayColor.color } + onChange={ setOverlayColor } + clearable={ false } + className="wp-block-cover__placeholder-color-palette" + /> + </MediaPlaceholder> + </> + ); + } + + const classes = classnames( + className, + dimRatioToClass( dimRatio ), + { + 'is-dark-theme': this.state.isDark, + 'has-background-dim': dimRatio !== 0, + 'has-parallax': hasParallax, + [ overlayColor.class ]: overlayColor.class, + } + ); + + return ( + <> + { controls } + <ResizableCover + className={ classnames( + 'block-library-cover__resize-container', + { 'is-selected': isSelected }, + ) } + onResizeStart={ () => toggleSelection( false ) } + onResize={ ( newMinHeight ) => { + this.setState( { + temporaryMinHeight: newMinHeight, + } ); + } } + onResizeStop={ + ( newMinHeight ) => { + toggleSelection( true ); + setAttributes( { + minHeight: newMinHeight, + } ); + this.setState( { + temporaryMinHeight: null, + } ); + } + } + > + + <div + data-url={ url } + style={ style } + className={ classes } + > + { IMAGE_BACKGROUND_TYPE === backgroundType && ( + // Used only to programmatically check if the image is dark or not + <img + ref={ this.imageRef } + aria-hidden + alt="" + style={ { + display: 'none', + } } + src={ url } + /> + ) } + { VIDEO_BACKGROUND_TYPE === backgroundType && ( + <video + ref={ this.videoRef } + className="wp-block-cover__video-background" + autoPlay + muted + loop + src={ url } + /> + ) } + <div className="wp-block-cover__inner-container"> + <InnerBlocks + template={ INNER_BLOCKS_TEMPLATE } + /> + </div> + </div> + </ResizableCover> + </> + ); + } + + handleBackgroundMode( prevProps ) { + const { attributes, overlayColor } = this.props; + const { dimRatio, url } = attributes; + // If opacity is greater than 50 the dominant color is the overlay color, + // so use that color for the dark mode computation. + if ( dimRatio > 50 ) { + if ( + prevProps && + prevProps.attributes.dimRatio > 50 && + prevProps.overlayColor.color === overlayColor.color + ) { + // No relevant prop changes happened there is no need to apply any change. + return; + } + if ( ! overlayColor.color ) { + // If no overlay color exists the overlay color is black (isDark ) + this.changeIsDarkIfRequired( true ); + return; + } + this.changeIsDarkIfRequired( + tinycolor( overlayColor.color ).isDark() + ); + return; + } + // If opacity is lower than 50 the dominant color is the image or video color, + // so use that color for the dark mode computation. + + if ( + prevProps && + prevProps.attributes.dimRatio <= 50 && + prevProps.attributes.url === url + ) { + // No relevant prop changes happened there is no need to apply any change. + return; + } + const { backgroundType } = attributes; + + let element; + + switch ( backgroundType ) { + case IMAGE_BACKGROUND_TYPE: + element = this.imageRef.current; + break; + case VIDEO_BACKGROUND_TYPE: + element = this.videoRef.current; + break; + } + if ( ! element ) { + return; + } + retrieveFastAverageColor().getColorAsync( element, ( color ) => { + this.changeIsDarkIfRequired( color.isDark ); + } ); + } + + changeIsDarkIfRequired( newIsDark ) { + if ( this.state.isDark !== newIsDark ) { + this.setState( { + isDark: newIsDark, + } ); + } + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { toggleSelection } = dispatch( 'core/block-editor' ); + + return { + toggleSelection, + }; + } ), + withColors( { overlayColor: 'background-color' } ), + withNotices, + withInstanceId, +] )( CoverEdit ); diff --git a/gb-src/packages/block-library/src/cover/editor.scss b/gb-src/packages/block-library/src/cover/editor.scss new file mode 100644 index 0000000000000..c7644f2c3a44c --- /dev/null +++ b/gb-src/packages/block-library/src/cover/editor.scss @@ -0,0 +1,63 @@ +.wp-block-cover-image, +.wp-block-cover { + + &.components-placeholder h2 { + color: inherit; + } + + &.has-right-content .block-editor-rich-text__inline-toolbar, + &.has-left-content .block-editor-rich-text__inline-toolbar { + display: inline-block; + } + + .block-editor-block-list__layout { + width: 100%; + } + + .block-editor-block-list__block { + color: $light-gray-100; + } + + // wp-block-cover class is used just to increase selector specificity + .wp-block-cover__inner-container { + // avoid text align inherit from cover image align. + text-align: left; + } + + .wp-block-cover__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout { + margin-left: 0; + margin-right: 0; + } + + &.components-placeholder { + // use opacity to work in various editor styles + background: $dark-opacity-light-200; + min-height: 200px; + + .is-dark-theme & { + background: $light-opacity-light-200; + } + } + + .wp-block-cover__placeholder-color-palette { + // wraps about 6 color swatches + max-width: 290px; + margin-top: 1em; + } + + // Apply max-width to floated items that have no intrinsic width + [data-align="left"] &, + [data-align="right"] & { + max-width: $content-width / 2; + width: 100%; + } +} + +.block-library-cover__reset-button { + margin-left: auto; +} + +.block-library-cover__resize-container:not(.is-resizing) { + // Important is used to have higher specificity than the inline style set by re-resizable library. + height: auto !important; +} diff --git a/gb-src/packages/block-library/src/cover/icon.js b/gb-src/packages/block-library/src/cover/icon.js new file mode 100644 index 0000000000000..abd932271d6eb --- /dev/null +++ b/gb-src/packages/block-library/src/cover/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2V4zm6 9l-4 5h12l-3-4-2.03 2.71L10 13zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5zM20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2zm0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2v7zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4v-7z" /><Path d="M0 0h24v24H0z" fill="none" /></SVG>; diff --git a/gb-src/packages/block-library/src/cover/index.js b/gb-src/packages/block-library/src/cover/index.js new file mode 100644 index 0000000000000..9983dc8a99796 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/index.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Cover' ), + description: __( 'Add an image or video with a text overlay — great for headers.' ), + icon, + supports: { + align: true, + }, + example: { + attributes: { + customOverlayColor: '#065174', + dimRatio: 40, + url: 'https://s.w.org/images/core/5.3/Windbuchencom.jpg', + }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + customFontSize: 48, + content: __( '<strong>Snow Patrol</strong>' ), + align: 'center', + }, + }, + ], + }, + transforms, + save, + edit, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/cover/save.js b/gb-src/packages/block-library/src/cover/save.js new file mode 100644 index 0000000000000..de057122a0f7b --- /dev/null +++ b/gb-src/packages/block-library/src/cover/save.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + InnerBlocks, + getColorClassName, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { + IMAGE_BACKGROUND_TYPE, + VIDEO_BACKGROUND_TYPE, + backgroundImageStyles, + dimRatioToClass, +} from './shared'; + +export default function save( { attributes } ) { + const { + backgroundType, + customOverlayColor, + dimRatio, + focalPoint, + hasParallax, + overlayColor, + url, + minHeight, + } = attributes; + const overlayColorClass = getColorClassName( 'background-color', overlayColor ); + const style = backgroundType === IMAGE_BACKGROUND_TYPE ? + backgroundImageStyles( url ) : + {}; + if ( ! overlayColorClass ) { + style.backgroundColor = customOverlayColor; + } + if ( focalPoint && ! hasParallax ) { + style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`; + } + style.minHeight = minHeight || undefined; + + const classes = classnames( + dimRatioToClass( dimRatio ), + overlayColorClass, + { + 'has-background-dim': dimRatio !== 0, + 'has-parallax': hasParallax, + }, + ); + + return ( + <div className={ classes } style={ style }> + { VIDEO_BACKGROUND_TYPE === backgroundType && url && ( <video + className="wp-block-cover__video-background" + autoPlay + muted + loop + src={ url } + /> ) } + <div className="wp-block-cover__inner-container"> + <InnerBlocks.Content /> + </div> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/cover/shared.js b/gb-src/packages/block-library/src/cover/shared.js new file mode 100644 index 0000000000000..af13f2b8f9b18 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/shared.js @@ -0,0 +1,14 @@ +export const IMAGE_BACKGROUND_TYPE = 'image'; +export const VIDEO_BACKGROUND_TYPE = 'video'; +export const COVER_MIN_HEIGHT = 50; +export function backgroundImageStyles( url ) { + return url ? + { backgroundImage: `url(${ url })` } : + {}; +} + +export function dimRatioToClass( ratio ) { + return ( ratio === 0 || ratio === 50 ) ? + null : + 'has-background-dim-' + ( 10 * Math.round( ratio / 10 ) ); +} diff --git a/gb-src/packages/block-library/src/cover/style.scss b/gb-src/packages/block-library/src/cover/style.scss new file mode 100644 index 0000000000000..5cf70fabe760b --- /dev/null +++ b/gb-src/packages/block-library/src/cover/style.scss @@ -0,0 +1,159 @@ +.wp-block-cover-image, +.wp-block-cover { + position: relative; + background-color: $black; + background-size: cover; + background-position: center center; + min-height: 430px; + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + + &.has-parallax { + background-attachment: fixed; + + // Mobile Safari does not support fixed background attachment properly. + // See also https://stackoverflow.com/questions/24154666/background-size-cover-not-working-on-ios + // Chrome on Android does not appear to support the attachment at all: https://issuetracker.google.com/issues/36908439 + @supports (-webkit-overflow-scrolling: touch) { + background-attachment: scroll; + } + + // Remove the appearance of scrolling based on OS-level animation preferences. + @media (prefers-reduced-motion: reduce) { + background-attachment: scroll; + } + } + + &.has-background-dim::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: inherit; + opacity: 0.5; + z-index: z-index(".wp-block-cover.has-background-dim::before"); + } + + @for $i from 1 through 10 { + &.has-background-dim.has-background-dim-#{ $i * 10 }::before { + opacity: $i * 0.1; + } + } + + // Apply max-width to floated items that have no intrinsic width + &.alignleft, + &.alignright { + max-width: $content-width / 2; + width: 100%; + } + + // Using flexbox without an assigned height property breaks vertical center alignment in IE11. + // Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue. + &::after { + display: block; + content: ""; + font-size: 0; + min-height: inherit; + + // IE doesn't support flex so omit that. + @supports (position: sticky) { + content: none; + } + } + + // Aligned cover blocks should not use our global alignment rules + &.aligncenter, + &.alignleft, + &.alignright { + display: flex; + } + + .wp-block-cover__inner-container { + width: calc(100% - 70px); + z-index: z-index(".wp-block-cover__inner-container"); + color: $light-gray-100; + } + + p, + h1, + h2, + h3, + h4, + h5, + h6, + .wp-block-subhead { + &:not(.has-text-color) { + color: inherit; + } + } +} + +.wp-block-cover__video-background { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + width: 100%; + height: 100%; + z-index: z-index(".wp-block-cover__video-background"); + object-fit: cover; +} + +// Styles bellow only exist to support older versions of the block. +// Versions that not had inner blocks and used an h2 heading had a section (and not a div) with a class wp-block-cover-image (and not a wp-block-cover). +// We are using the previous referred differences to target old versions. + +section.wp-block-cover-image h2, +.wp-block-cover-image-text, +.wp-block-cover-text { + color: $white; + a, + a:hover, + a:focus, + a:active { + color: $white; + } +} + +.wp-block-cover-image +.wp-block-cover { + &.has-left-content { + justify-content: flex-start; + } + + &.has-right-content { + justify-content: flex-end; + } +} + +section.wp-block-cover-image.has-left-content > h2, +.wp-block-cover-image.has-left-content .wp-block-cover-image-text, +.wp-block-cover.has-left-content .wp-block-cover-text { + margin-left: 0; + text-align: left; +} + +section.wp-block-cover-image.has-right-content > h2, +.wp-block-cover-image.has-right-content .wp-block-cover-image-text, +.wp-block-cover.has-right-content .wp-block-cover-text { + margin-right: 0; + text-align: right; +} + +section.wp-block-cover-image > h2, +.wp-block-cover-image .wp-block-cover-image-text, +.wp-block-cover .wp-block-cover-text { + font-size: 2em; + line-height: 1.25; + z-index: 1; + margin-bottom: 0; + max-width: $content-width; + padding: $block-padding; + text-align: center; +} diff --git a/gb-src/packages/block-library/src/cover/transforms.js b/gb-src/packages/block-library/src/cover/transforms.js new file mode 100644 index 0000000000000..8b775f73d0040 --- /dev/null +++ b/gb-src/packages/block-library/src/cover/transforms.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { IMAGE_BACKGROUND_TYPE, VIDEO_BACKGROUND_TYPE } from './shared'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( { caption, url, align, id } ) => ( + createBlock( 'core/cover', { + title: caption, + url, + align, + id, + } ) + ), + }, + { + type: 'block', + blocks: [ 'core/video' ], + transform: ( { caption, src, align, id } ) => ( + createBlock( 'core/cover', { + title: caption, + url: src, + align, + id, + backgroundType: VIDEO_BACKGROUND_TYPE, + } ) + ), + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/image' ], + isMatch: ( { backgroundType, url } ) => { + return ! url || backgroundType === IMAGE_BACKGROUND_TYPE; + }, + transform: ( { title, url, align, id } ) => ( + createBlock( 'core/image', { + caption: title, + url, + align, + id, + } ) + ), + }, + { + type: 'block', + blocks: [ 'core/video' ], + isMatch: ( { backgroundType, url } ) => { + return ! url || backgroundType === VIDEO_BACKGROUND_TYPE; + }, + transform: ( { title, url, align, id } ) => ( + createBlock( 'core/video', { + caption: title, + src: url, + id, + align, + } ) + ), + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/editor.scss b/gb-src/packages/block-library/src/editor.scss new file mode 100644 index 0000000000000..3b38a4e136640 --- /dev/null +++ b/gb-src/packages/block-library/src/editor.scss @@ -0,0 +1,60 @@ +@import "./archives/editor.scss"; +@import "./audio/editor.scss"; +@import "./block/editor.scss"; +@import "./button/editor.scss"; +@import "./categories/editor.scss"; +@import "./code/editor.scss"; +@import "./columns/editor.scss"; +@import "./cover/editor.scss"; +@import "./embed/editor.scss"; +@import "./file/editor.scss"; +@import "./classic/editor.scss"; +@import "./gallery/editor.scss"; +@import "./group/editor.scss"; +@import "./html/editor.scss"; +@import "./image/editor.scss"; +@import "./latest-comments/editor.scss"; +@import "./latest-posts/editor.scss"; +@import "./legacy-widget/editor.scss"; +@import "./media-text/editor.scss"; +@import "./list/editor.scss"; +@import "./more/editor.scss"; +@import "./navigation-menu/editor.scss"; +@import "./navigation-menu-item/editor.scss"; +@import "./nextpage/editor.scss"; +@import "./paragraph/editor.scss"; +@import "./preformatted/editor.scss"; +@import "./pullquote/editor.scss"; +@import "./quote/editor.scss"; +@import "./rss/editor.scss"; +@import "./search/editor.scss"; +@import "./shortcode/editor.scss"; +@import "./social-link/editor.scss"; +@import "./social-links/editor.scss"; +@import "./spacer/editor.scss"; +@import "./subhead/editor.scss"; +@import "./table/editor.scss"; +@import "./tag-cloud/editor.scss"; +@import "./text-columns/editor.scss"; +@import "./verse/editor.scss"; +@import "./video/editor.scss"; + +/** + * Import styles from internal editor components used by the blocks. + */ +@import "./block/edit-panel/editor.scss"; +@import "./block/indicator/editor.scss"; + +/** + * Editor Normalization Styles + * + * These are only output in the editor, but styles here are NOT prefixed .editor-styles-wrapper. + * This allows us to create normalization styles that are easily overridden by editor styles. + */ + +// Provide every block with a default base margin. This margin provides a consistent spacing +// between blocks in the editor. +.editor-styles-wrapper [data-block] { + margin-top: $default-block-margin; + margin-bottom: $default-block-margin; +} diff --git a/gb-src/packages/block-library/src/embed/constants.js b/gb-src/packages/block-library/src/embed/constants.js new file mode 100644 index 0000000000000..cf26c58f182d8 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/constants.js @@ -0,0 +1,17 @@ +// These embeds do not work in sandboxes due to the iframe's security restrictions. +export const HOSTS_NO_PREVIEWS = [ 'facebook.com', 'smugmug.com' ]; + +export const ASPECT_RATIOS = [ + // Common video resolutions. + { ratio: '2.33', className: 'wp-embed-aspect-21-9' }, + { ratio: '2.00', className: 'wp-embed-aspect-18-9' }, + { ratio: '1.78', className: 'wp-embed-aspect-16-9' }, + { ratio: '1.33', className: 'wp-embed-aspect-4-3' }, + // Vertical video and instagram square video support. + { ratio: '1.00', className: 'wp-embed-aspect-1-1' }, + { ratio: '0.56', className: 'wp-embed-aspect-9-16' }, + { ratio: '0.50', className: 'wp-embed-aspect-1-2' }, +]; + +export const DEFAULT_EMBED_BLOCK = 'core/embed'; +export const WORDPRESS_EMBED_BLOCK = 'core-embed/wordpress'; diff --git a/gb-src/packages/block-library/src/embed/core-embeds.js b/gb-src/packages/block-library/src/embed/core-embeds.js new file mode 100644 index 0000000000000..b21d3baee4ae6 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/core-embeds.js @@ -0,0 +1,383 @@ +/** + * Internal dependencies + */ +import { + embedContentIcon, + embedAudioIcon, + embedPhotoIcon, + embedVideoIcon, + embedTwitterIcon, + embedYouTubeIcon, + embedFacebookIcon, + embedInstagramIcon, + embedWordPressIcon, + embedSpotifyIcon, + embedFlickrIcon, + embedVimeoIcon, + embedRedditIcon, + embedTumbrIcon, + embedAmazonIcon, +} from './icons'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; + +export const common = [ + { + name: 'core-embed/twitter', + settings: { + title: 'Twitter', + icon: embedTwitterIcon, + keywords: [ 'tweet' ], + description: __( 'Embed a tweet.' ), + }, + patterns: [ /^https?:\/\/(www\.)?twitter\.com\/.+/i ], + }, + { + name: 'core-embed/youtube', + settings: { + title: 'YouTube', + icon: embedYouTubeIcon, + keywords: [ __( 'music' ), __( 'video' ) ], + description: __( 'Embed a YouTube video.' ), + }, + patterns: [ /^https?:\/\/((m|www)\.)?youtube\.com\/.+/i, /^https?:\/\/youtu\.be\/.+/i ], + }, + { + name: 'core-embed/facebook', + settings: { + title: 'Facebook', + icon: embedFacebookIcon, + description: __( 'Embed a Facebook post.' ), + }, + patterns: [ /^https?:\/\/www\.facebook.com\/.+/i ], + }, + { + name: 'core-embed/instagram', + settings: { + title: 'Instagram', + icon: embedInstagramIcon, + keywords: [ __( 'image' ) ], + description: __( 'Embed an Instagram post.' ), + }, + patterns: [ /^https?:\/\/(www\.)?instagr(\.am|am\.com)\/.+/i ], + }, + { + name: 'core-embed/wordpress', + settings: { + title: 'WordPress', + icon: embedWordPressIcon, + keywords: [ __( 'post' ), __( 'blog' ) ], + responsive: false, + description: __( 'Embed a WordPress post.' ), + }, + }, + { + name: 'core-embed/soundcloud', + settings: { + title: 'SoundCloud', + icon: embedAudioIcon, + keywords: [ __( 'music' ), __( 'audio' ) ], + description: __( 'Embed SoundCloud content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?soundcloud\.com\/.+/i ], + }, + { + name: 'core-embed/spotify', + settings: { + title: 'Spotify', + icon: embedSpotifyIcon, + keywords: [ __( 'music' ), __( 'audio' ) ], + description: __( 'Embed Spotify content.' ), + }, + patterns: [ /^https?:\/\/(open|play)\.spotify\.com\/.+/i ], + }, + { + name: 'core-embed/flickr', + settings: { + title: 'Flickr', + icon: embedFlickrIcon, + keywords: [ __( 'image' ) ], + description: __( 'Embed Flickr content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?flickr\.com\/.+/i, /^https?:\/\/flic\.kr\/.+/i ], + }, + { + name: 'core-embed/vimeo', + settings: { + title: 'Vimeo', + icon: embedVimeoIcon, + keywords: [ __( 'video' ) ], + description: __( 'Embed a Vimeo video.' ), + }, + patterns: [ /^https?:\/\/(www\.)?vimeo\.com\/.+/i ], + }, +]; + +export const others = [ + { + name: 'core-embed/animoto', + settings: { + title: 'Animoto', + icon: embedVideoIcon, + description: __( 'Embed an Animoto video.' ), + }, + patterns: [ /^https?:\/\/(www\.)?(animoto|video214)\.com\/.+/i ], + }, + { + name: 'core-embed/cloudup', + settings: { + title: 'Cloudup', + icon: embedContentIcon, + description: __( 'Embed Cloudup content.' ), + }, + patterns: [ /^https?:\/\/cloudup\.com\/.+/i ], + }, + { + // Deprecated since CollegeHumor content is now powered by YouTube + name: 'core-embed/collegehumor', + settings: { + title: 'CollegeHumor', + icon: embedVideoIcon, + description: __( 'Embed CollegeHumor content.' ), + supports: { + inserter: false, + }, + }, + patterns: [], + }, + { + name: 'core-embed/crowdsignal', + settings: { + title: 'Crowdsignal', + icon: embedContentIcon, + keywords: [ 'polldaddy' ], + transform: [ { + type: 'block', + blocks: [ 'core-embed/polldaddy' ], + transform: ( content ) => { + return createBlock( 'core-embed/crowdsignal', { + content, + } ); + }, + } ], + description: __( 'Embed Crowdsignal (formerly Polldaddy) content.' ), + }, + patterns: [ /^https?:\/\/((.+\.)?polldaddy\.com|poll\.fm|.+\.survey\.fm)\/.+/i ], + }, + { + name: 'core-embed/dailymotion', + settings: { + title: 'Dailymotion', + icon: embedVideoIcon, + description: __( 'Embed a Dailymotion video.' ), + }, + patterns: [ /^https?:\/\/(www\.)?dailymotion\.com\/.+/i ], + }, + { + name: 'core-embed/hulu', + settings: { + title: 'Hulu', + icon: embedVideoIcon, + description: __( 'Embed Hulu content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?hulu\.com\/.+/i ], + }, + { + name: 'core-embed/imgur', + settings: { + title: 'Imgur', + icon: embedPhotoIcon, + description: __( 'Embed Imgur content.' ), + }, + patterns: [ /^https?:\/\/(.+\.)?imgur\.com\/.+/i ], + }, + { + name: 'core-embed/issuu', + settings: { + title: 'Issuu', + icon: embedContentIcon, + description: __( 'Embed Issuu content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?issuu\.com\/.+/i ], + }, + { + name: 'core-embed/kickstarter', + settings: { + title: 'Kickstarter', + icon: embedContentIcon, + description: __( 'Embed Kickstarter content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?kickstarter\.com\/.+/i, /^https?:\/\/kck\.st\/.+/i ], + }, + { + name: 'core-embed/meetup-com', + settings: { + title: 'Meetup.com', + icon: embedContentIcon, + description: __( 'Embed Meetup.com content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?meetu(\.ps|p\.com)\/.+/i ], + }, + { + name: 'core-embed/mixcloud', + settings: { + title: 'Mixcloud', + icon: embedAudioIcon, + keywords: [ __( 'music' ), __( 'audio' ) ], + description: __( 'Embed Mixcloud content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?mixcloud\.com\/.+/i ], + }, + { + // Deprecated in favour of the core-embed/crowdsignal block + name: 'core-embed/polldaddy', + settings: { + title: 'Polldaddy', + icon: embedContentIcon, + description: __( 'Embed Polldaddy content.' ), + supports: { + inserter: false, + }, + }, + patterns: [], + }, + { + name: 'core-embed/reddit', + settings: { + title: 'Reddit', + icon: embedRedditIcon, + description: __( 'Embed a Reddit thread.' ), + }, + patterns: [ /^https?:\/\/(www\.)?reddit\.com\/.+/i ], + }, + { + name: 'core-embed/reverbnation', + settings: { + title: 'ReverbNation', + icon: embedAudioIcon, + description: __( 'Embed ReverbNation content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?reverbnation\.com\/.+/i ], + }, + { + name: 'core-embed/screencast', + settings: { + title: 'Screencast', + icon: embedVideoIcon, + description: __( 'Embed Screencast content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?screencast\.com\/.+/i ], + }, + { + name: 'core-embed/scribd', + settings: { + title: 'Scribd', + icon: embedContentIcon, + description: __( 'Embed Scribd content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?scribd\.com\/.+/i ], + }, + { + name: 'core-embed/slideshare', + settings: { + title: 'Slideshare', + icon: embedContentIcon, + description: __( 'Embed Slideshare content.' ), + }, + patterns: [ /^https?:\/\/(.+?\.)?slideshare\.net\/.+/i ], + }, + { + name: 'core-embed/smugmug', + settings: { + title: 'SmugMug', + icon: embedPhotoIcon, + description: __( 'Embed SmugMug content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?smugmug\.com\/.+/i ], + }, + { + // Deprecated in favour of the core-embed/speaker-deck block. + name: 'core-embed/speaker', + settings: { + title: 'Speaker', + icon: embedAudioIcon, + supports: { + inserter: false, + }, + }, + patterns: [], + }, + { + name: 'core-embed/speaker-deck', + settings: { + title: 'Speaker Deck', + icon: embedContentIcon, + transform: [ { + type: 'block', + blocks: [ 'core-embed/speaker' ], + transform: ( content ) => { + return createBlock( 'core-embed/speaker-deck', { + content, + } ); + }, + } ], + description: __( 'Embed Speaker Deck content.' ), + }, + patterns: [ /^https?:\/\/(www\.)?speakerdeck\.com\/.+/i ], + }, + { + name: 'core-embed/ted', + settings: { + title: 'TED', + icon: embedVideoIcon, + description: __( 'Embed a TED video.' ), + }, + patterns: [ /^https?:\/\/(www\.|embed\.)?ted\.com\/.+/i ], + }, + { + name: 'core-embed/tumblr', + settings: { + title: 'Tumblr', + icon: embedTumbrIcon, + description: __( 'Embed a Tumblr post.' ), + }, + patterns: [ /^https?:\/\/(www\.)?tumblr\.com\/.+/i ], + }, + { + name: 'core-embed/videopress', + settings: { + title: 'VideoPress', + icon: embedVideoIcon, + keywords: [ __( 'video' ) ], + description: __( 'Embed a VideoPress video.' ), + }, + patterns: [ /^https?:\/\/videopress\.com\/.+/i ], + }, + { + name: 'core-embed/wordpress-tv', + settings: { + title: 'WordPress.tv', + icon: embedVideoIcon, + description: __( 'Embed a WordPress.tv video.' ), + }, + patterns: [ /^https?:\/\/wordpress\.tv\/.+/i ], + }, + { + name: 'core-embed/amazon-kindle', + settings: { + title: 'Amazon Kindle', + icon: embedAmazonIcon, + keywords: [ __( 'ebook' ) ], + responsive: false, + description: __( 'Embed Amazon Kindle content.' ), + }, + patterns: [ + /^https?:\/\/([a-z0-9-]+\.)?(amazon|amzn)(\.[a-z]{2,4})+\/.+/i, + /^https?:\/\/(www\.)?(a\.co|z\.cn)\/.+/i, + ], + }, +]; diff --git a/gb-src/packages/block-library/src/embed/edit.js b/gb-src/packages/block-library/src/embed/edit.js new file mode 100644 index 0000000000000..b92d07d2927d3 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/edit.js @@ -0,0 +1,198 @@ +/** + * Internal dependencies + */ +import { createUpgradedEmbedBlock, getClassNames, fallback, getAttributesFromPreview } from './util'; +import EmbedControls from './embed-controls'; +import EmbedLoading from './embed-loading'; +import EmbedPlaceholder from './embed-placeholder'; +import EmbedPreview from './embed-preview'; + +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; + +export function getEmbedEditComponent( title, icon, responsive = true ) { + return class extends Component { + constructor() { + super( ...arguments ); + this.switchBackToURLInput = this.switchBackToURLInput.bind( this ); + this.setUrl = this.setUrl.bind( this ); + this.getMergedAttributes = this.getMergedAttributes.bind( this ); + this.setMergedAttributes = this.setMergedAttributes.bind( this ); + this.getResponsiveHelp = this.getResponsiveHelp.bind( this ); + this.toggleResponsive = this.toggleResponsive.bind( this ); + this.handleIncomingPreview = this.handleIncomingPreview.bind( this ); + + this.state = { + editingURL: false, + url: this.props.attributes.url, + }; + + if ( this.props.preview ) { + this.handleIncomingPreview(); + } + } + + handleIncomingPreview() { + this.setMergedAttributes(); + if ( this.props.onReplace ) { + const upgradedBlock = createUpgradedEmbedBlock( + this.props, + this.getMergedAttributes() + ); + if ( upgradedBlock ) { + this.props.onReplace( upgradedBlock ); + } + } + } + + componentDidUpdate( prevProps ) { + const hasPreview = undefined !== this.props.preview; + const hadPreview = undefined !== prevProps.preview; + const previewChanged = prevProps.preview && this.props.preview && this.props.preview.html !== prevProps.preview.html; + const switchedPreview = previewChanged || ( hasPreview && ! hadPreview ); + const switchedURL = this.props.attributes.url !== prevProps.attributes.url; + + if ( switchedPreview || switchedURL ) { + if ( this.props.cannotEmbed ) { + // We either have a new preview or a new URL, but we can't embed it. + if ( ! this.props.fetching ) { + // If we're not fetching the preview, then we know it can't be embedded, so try + // removing any trailing slash, and resubmit. + this.resubmitWithoutTrailingSlash(); + } + return; + } + this.handleIncomingPreview(); + } + } + + resubmitWithoutTrailingSlash() { + this.setState( ( prevState ) => ( { + url: prevState.url.replace( /\/$/, '' ), + } ), this.setUrl ); + } + + setUrl( event ) { + if ( event ) { + event.preventDefault(); + } + const { url } = this.state; + const { setAttributes } = this.props; + this.setState( { editingURL: false } ); + setAttributes( { url } ); + } + + /*** + * @return {Object} Attributes derived from the preview, merged with the current attributes. + */ + getMergedAttributes() { + const { preview } = this.props; + const { className, allowResponsive } = this.props.attributes; + return { ...this.props.attributes, ...getAttributesFromPreview( preview, title, className, responsive, allowResponsive ) }; + } + + /*** + * Sets block attributes based on the current attributes and preview data. + */ + setMergedAttributes() { + const { setAttributes } = this.props; + setAttributes( this.getMergedAttributes() ); + } + + switchBackToURLInput() { + this.setState( { editingURL: true } ); + } + + getResponsiveHelp( checked ) { + return checked ? __( 'This embed will preserve its aspect ratio when the browser is resized.' ) : __( 'This embed may not preserve its aspect ratio when the browser is resized.' ); + } + + toggleResponsive() { + const { allowResponsive, className } = this.props.attributes; + const { html } = this.props.preview; + const newAllowResponsive = ! allowResponsive; + + this.props.setAttributes( + { + allowResponsive: newAllowResponsive, + className: getClassNames( html, className, responsive && newAllowResponsive ), + } + ); + } + + render() { + const { url, editingURL } = this.state; + const { fetching, setAttributes, isSelected, preview, cannotEmbed, themeSupportsResponsive, tryAgain } = this.props; + + if ( fetching ) { + return ( + <EmbedLoading /> + ); + } + + // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists + const label = sprintf( __( '%s URL' ), title ); + + // No preview, or we can't embed the current URL, or we've clicked the edit button. + if ( ! preview || cannotEmbed || editingURL ) { + return ( + <EmbedPlaceholder + icon={ icon } + label={ label } + onSubmit={ this.setUrl } + value={ url } + cannotEmbed={ cannotEmbed } + onChange={ ( event ) => this.setState( { url: event.target.value } ) } + fallback={ () => fallback( url, this.props.onReplace ) } + tryAgain={ tryAgain } + /> + ); + } + + // Even though we set attributes that get derived from the preview, + // we don't access them directly because for the initial render, + // the `setAttributes` call will not have taken effect. If we're + // rendering responsive content, setting the responsive classes + // after the preview has been rendered can result in unwanted + // clipping or scrollbars. The `getAttributesFromPreview` function + // that `getMergedAttributes` uses is memoized so that we're not + // calculating them on every render. + const previewAttributes = this.getMergedAttributes(); + const { caption, type, allowResponsive } = previewAttributes; + const className = classnames( previewAttributes.className, this.props.className ); + + return ( + <> + <EmbedControls + showEditButton={ preview && ! cannotEmbed } + themeSupportsResponsive={ themeSupportsResponsive } + blockSupportsResponsive={ responsive } + allowResponsive={ allowResponsive } + getResponsiveHelp={ this.getResponsiveHelp } + toggleResponsive={ this.toggleResponsive } + switchBackToURLInput={ this.switchBackToURLInput } + /> + <EmbedPreview + preview={ preview } + className={ className } + url={ url } + type={ type } + caption={ caption } + onCaptionChange={ ( value ) => setAttributes( { caption: value } ) } + isSelected={ isSelected } + icon={ icon } + label={ label } + /> + </> + ); + } + }; +} diff --git a/gb-src/packages/block-library/src/embed/editor.scss b/gb-src/packages/block-library/src/embed/editor.scss new file mode 100644 index 0000000000000..342b4fe1b116b --- /dev/null +++ b/gb-src/packages/block-library/src/embed/editor.scss @@ -0,0 +1,53 @@ +.wp-block-embed { + // Remove the left and right margin the figure is born with. + margin-left: 0; + margin-right: 0; + + // Necessary because we use responsive trickery to set width/height, + // therefore the video doesn't intrinsically clear floats like an image does. + clear: both; + + // Apply a min-width, or the embed can collapse when floated. + // Instagram widgets have a min-width of 326px, so go a bit beyond that. + @include break-small() { + min-width: 360px; + + // The placeholder should not have this min-width. + &.components-placeholder { + min-width: 0; + } + } + + &.is-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1em; + min-height: 200px; + text-align: center; + background: $light-gray-100; + p { + font-family: $default-font; + font-size: $default-font-size; + } + } + + // Stops long URLs from breaking out of the no preview available screen + .components-placeholder__error { + word-break: break-word; + } + + .components-placeholder__learn-more { + margin-top: 1em; + } +} + +.block-library-embed__interactive-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; +} diff --git a/gb-src/packages/block-library/src/embed/embed-controls.js b/gb-src/packages/block-library/src/embed/embed-controls.js new file mode 100644 index 0000000000000..fe115b403be22 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/embed-controls.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { IconButton, Toolbar, PanelBody, ToggleControl } from '@wordpress/components'; +import { BlockControls, InspectorControls } from '@wordpress/block-editor'; + +const EmbedControls = ( props ) => { + const { + blockSupportsResponsive, + showEditButton, + themeSupportsResponsive, + allowResponsive, + getResponsiveHelp, + toggleResponsive, + switchBackToURLInput, + } = props; + return ( + <> + <BlockControls> + <Toolbar> + { showEditButton && ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit URL' ) } + icon="edit" + onClick={ switchBackToURLInput } + /> + ) } + </Toolbar> + </BlockControls> + { themeSupportsResponsive && blockSupportsResponsive && ( + <InspectorControls> + <PanelBody title={ __( 'Media Settings' ) } className="blocks-responsive"> + <ToggleControl + label={ __( 'Resize for smaller devices' ) } + checked={ allowResponsive } + help={ getResponsiveHelp } + onChange={ toggleResponsive } + /> + </PanelBody> + </InspectorControls> + ) } + </> + ); +}; + +export default EmbedControls; diff --git a/gb-src/packages/block-library/src/embed/embed-loading.js b/gb-src/packages/block-library/src/embed/embed-loading.js new file mode 100644 index 0000000000000..f643cdc9c1dc1 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/embed-loading.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Spinner } from '@wordpress/components'; + +const EmbedLoading = () => ( + <div className="wp-block-embed is-loading"> + <Spinner /> + <p>{ __( 'Embedding…' ) }</p> + </div> +); + +export default EmbedLoading; diff --git a/gb-src/packages/block-library/src/embed/embed-placeholder.js b/gb-src/packages/block-library/src/embed/embed-placeholder.js new file mode 100644 index 0000000000000..7a2a6f000a01f --- /dev/null +++ b/gb-src/packages/block-library/src/embed/embed-placeholder.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { Button, Placeholder, ExternalLink } from '@wordpress/components'; +import { BlockIcon } from '@wordpress/block-editor'; + +const EmbedPlaceholder = ( props ) => { + const { icon, label, value, onSubmit, onChange, cannotEmbed, fallback, tryAgain } = props; + return ( + <Placeholder + icon={ <BlockIcon icon={ icon } showColors /> } + label={ label } + className="wp-block-embed" + instructions={ __( 'Paste a link to the content you want to display on your site.' ) } + > + <form onSubmit={ onSubmit }> + <input + type="url" + value={ value || '' } + className="components-placeholder__input" + aria-label={ label } + placeholder={ __( 'Enter URL to embed here…' ) } + onChange={ onChange } /> + <Button + isLarge + type="submit"> + { _x( 'Embed', 'button label' ) } + </Button> + { cannotEmbed && + <p className="components-placeholder__error"> + { __( 'Sorry, this content could not be embedded.' ) }<br /> + <Button isLarge onClick={ tryAgain }>{ _x( 'Try again', 'button label' ) }</Button> <Button isLarge onClick={ fallback }>{ _x( 'Convert to link', 'button label' ) }</Button> + </p> + } + </form> + <div className="components-placeholder__learn-more"> + <ExternalLink href={ __( 'https://wordpress.org/support/article/embeds/' ) }> + { __( 'Learn more about embeds' ) } + </ExternalLink> + </div> + </Placeholder> + ); +}; + +export default EmbedPlaceholder; diff --git a/gb-src/packages/block-library/src/embed/embed-preview.js b/gb-src/packages/block-library/src/embed/embed-preview.js new file mode 100644 index 0000000000000..878c3e7504cca --- /dev/null +++ b/gb-src/packages/block-library/src/embed/embed-preview.js @@ -0,0 +1,120 @@ +/** + * Internal dependencies + */ +import { HOSTS_NO_PREVIEWS } from './constants'; +import { getPhotoHtml } from './util'; + +/** + * External dependencies + */ +import { parse } from 'url'; +import { includes } from 'lodash'; +import classnames from 'classnames/dedupe'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Placeholder, SandBox } from '@wordpress/components'; +import { RichText, BlockIcon } from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import WpEmbedPreview from './wp-embed-preview'; + +class EmbedPreview extends Component { + constructor() { + super( ...arguments ); + this.hideOverlay = this.hideOverlay.bind( this ); + this.state = { + interactive: false, + }; + } + + static getDerivedStateFromProps( nextProps, state ) { + if ( ! nextProps.isSelected && state.interactive ) { + // We only want to change this when the block is not selected, because changing it when + // the block becomes selected makes the overlap disappear too early. Hiding the overlay + // happens on mouseup when the overlay is clicked. + return { interactive: false }; + } + + return null; + } + + hideOverlay() { + // This is called onMouseUp on the overlay. We can't respond to the `isSelected` prop + // changing, because that happens on mouse down, and the overlay immediately disappears, + // and the mouse event can end up in the preview content. We can't use onClick on + // the overlay to hide it either, because then the editor misses the mouseup event, and + // thinks we're multi-selecting blocks. + this.setState( { interactive: true } ); + } + + render() { + const { preview, url, type, caption, onCaptionChange, isSelected, className, icon, label } = this.props; + const { scripts } = preview; + const { interactive } = this.state; + + const html = 'photo' === type ? getPhotoHtml( preview ) : preview.html; + const parsedHost = parse( url ).host.split( '.' ); + const parsedHostBaseUrl = parsedHost.splice( parsedHost.length - 2, parsedHost.length - 1 ).join( '.' ); + const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedHostBaseUrl ); + // translators: %s: host providing embed content e.g: www.youtube.com + const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedHostBaseUrl ); + const sandboxClassnames = classnames( type, className, 'wp-block-embed__wrapper' ); + + // Disabled because the overlay div doesn't actually have a role or functionality + // as far as the user is concerned. We're just catching the first click so that + // the block can be selected without interacting with the embed preview that the overlay covers. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + const embedWrapper = 'wp-embed' === type ? ( + <WpEmbedPreview + html={ html } + /> + ) : ( + <div className="wp-block-embed__wrapper"> + <SandBox + html={ html } + scripts={ scripts } + title={ iframeTitle } + type={ sandboxClassnames } + onFocus={ this.hideOverlay } + /> + { ! interactive && <div + className="block-library-embed__interactive-overlay" + onMouseUp={ this.hideOverlay } /> } + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + + return ( + <figure className={ classnames( className, 'wp-block-embed', { 'is-type-video': 'video' === type } ) }> + { ( cannotPreview ) ? ( + <Placeholder icon={ <BlockIcon icon={ icon } showColors /> } label={ label }> + <p className="components-placeholder__error"><a href={ url }>{ url }</a></p> + <p className="components-placeholder__error"> + { + /* translators: %s: host providing embed content e.g: www.youtube.com */ + sprintf( __( "Embedded content from %s can't be previewed in the editor." ), parsedHostBaseUrl ) + } + </p> + </Placeholder> + ) : embedWrapper } + { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + onChange={ onCaptionChange } + inlineToolbar + /> + ) } + </figure> + ); + } +} + +export default EmbedPreview; diff --git a/gb-src/packages/block-library/src/embed/icons.js b/gb-src/packages/block-library/src/embed/icons.js new file mode 100644 index 0000000000000..21f042479080b --- /dev/null +++ b/gb-src/packages/block-library/src/embed/icons.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { + G, + Path, + Polygon, + SVG, +} from '@wordpress/components'; + +export const embedContentIcon = <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="M19,4H5C3.89,4,3,4.9,3,6v12c0,1.1,0.89,2,2,2h14c1.1,0,2-0.9,2-2V6C21,4.9,20.11,4,19,4z M19,18H5V8h14V18z" /></SVG>; +export const embedAudioIcon = <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM8 15c0-1.66 1.34-3 3-3 .35 0 .69.07 1 .18V6h5v2h-3v7.03c-.02 1.64-1.35 2.97-3 2.97-1.66 0-3-1.34-3-3z" /></SVG>; +export const embedPhotoIcon = <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="M21,4H3C1.9,4,1,4.9,1,6v12c0,1.1,0.9,2,2,2h18c1.1,0,2-0.9,2-2V6C23,4.9,22.1,4,21,4z M21,18H3V6h18V18z" /><Polygon points="14.5 11 11 15.51 8.5 12.5 5 17 19 17" /></SVG>; +export const embedVideoIcon = <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="m10 8v8l5-4-5-4zm9-5h-14c-1.1 0-2 0.9-2 2v14c0 1.1 0.9 2 2 2h14c1.1 0 2-0.9 2-2v-14c0-1.1-0.9-2-2-2zm0 16h-14v-14h14v14z" /></SVG>; +export const embedTwitterIcon = { + foreground: '#1da1f2', + src: <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><G><Path d="M22.23 5.924c-.736.326-1.527.547-2.357.646.847-.508 1.498-1.312 1.804-2.27-.793.47-1.67.812-2.606.996C18.325 4.498 17.258 4 16.078 4c-2.266 0-4.103 1.837-4.103 4.103 0 .322.036.635.106.935-3.41-.17-6.433-1.804-8.457-4.287-.353.607-.556 1.312-.556 2.064 0 1.424.724 2.68 1.825 3.415-.673-.022-1.305-.207-1.86-.514v.052c0 1.988 1.415 3.647 3.293 4.023-.344.095-.707.145-1.08.145-.265 0-.522-.026-.773-.074.522 1.63 2.038 2.817 3.833 2.85-1.404 1.1-3.174 1.757-5.096 1.757-.332 0-.66-.02-.98-.057 1.816 1.164 3.973 1.843 6.29 1.843 7.547 0 11.675-6.252 11.675-11.675 0-.178-.004-.355-.012-.53.802-.578 1.497-1.3 2.047-2.124z"></Path></G></SVG>, +}; +export const embedYouTubeIcon = { + foreground: '#ff0000', + src: <SVG viewBox="0 0 24 24"><Path d="M21.8 8s-.195-1.377-.795-1.984c-.76-.797-1.613-.8-2.004-.847-2.798-.203-6.996-.203-6.996-.203h-.01s-4.197 0-6.996.202c-.39.046-1.242.05-2.003.846C2.395 6.623 2.2 8 2.2 8S2 9.62 2 11.24v1.517c0 1.618.2 3.237.2 3.237s.195 1.378.795 1.985c.76.797 1.76.77 2.205.855 1.6.153 6.8.2 6.8.2s4.203-.005 7-.208c.392-.047 1.244-.05 2.005-.847.6-.607.795-1.985.795-1.985s.2-1.618.2-3.237v-1.517C22 9.62 21.8 8 21.8 8zM9.935 14.595v-5.62l5.403 2.82-5.403 2.8z" /></SVG>, +}; +export const embedFacebookIcon = { + foreground: '#3b5998', + src: <SVG viewBox="0 0 24 24"><Path d="M20 3H4c-.6 0-1 .4-1 1v16c0 .5.4 1 1 1h8.6v-7h-2.3v-2.7h2.3v-2c0-2.3 1.4-3.6 3.5-3.6 1 0 1.8.1 2.1.1v2.4h-1.4c-1.1 0-1.3.5-1.3 1.3v1.7h2.7l-.4 2.8h-2.3v7H20c.5 0 1-.4 1-1V4c0-.6-.4-1-1-1z" /></SVG>, +}; +export const embedInstagramIcon = <SVG viewBox="0 0 24 24"><G><Path d="M12 4.622c2.403 0 2.688.01 3.637.052.877.04 1.354.187 1.67.31.42.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.67.043.95.052 1.235.052 3.638s-.01 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.42-.358.72-.673 1.036-.315.315-.615.51-1.035.673-.317.123-.794.27-1.67.31-.95.043-1.234.052-3.638.052s-2.688-.01-3.637-.052c-.877-.04-1.354-.187-1.67-.31-.42-.163-.72-.358-1.036-.673-.315-.315-.51-.615-.673-1.035-.123-.317-.27-.794-.31-1.67-.043-.95-.052-1.235-.052-3.638s.01-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.42.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.67-.31.95-.043 1.235-.052 3.638-.052M12 3c-2.444 0-2.75.01-3.71.054s-1.613.196-2.185.418c-.592.23-1.094.538-1.594 1.04-.5.5-.807 1-1.037 1.593-.223.572-.375 1.226-.42 2.184C3.01 9.25 3 9.555 3 12s.01 2.75.054 3.71.196 1.613.418 2.186c.23.592.538 1.094 1.038 1.594s1.002.808 1.594 1.038c.572.222 1.227.375 2.185.418.96.044 1.266.054 3.71.054s2.75-.01 3.71-.054 1.613-.196 2.186-.418c.592-.23 1.094-.538 1.594-1.038s.808-1.002 1.038-1.594c.222-.572.375-1.227.418-2.185.044-.96.054-1.266.054-3.71s-.01-2.75-.054-3.71-.196-1.613-.418-2.186c-.23-.592-.538-1.094-1.038-1.594s-1.002-.808-1.594-1.038c-.572-.222-1.227-.375-2.185-.418C14.75 3.01 14.445 3 12 3zm0 4.378c-2.552 0-4.622 2.07-4.622 4.622s2.07 4.622 4.622 4.622 4.622-2.07 4.622-4.622S14.552 7.378 12 7.378zM12 15c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zm4.804-8.884c-.596 0-1.08.484-1.08 1.08s.484 1.08 1.08 1.08c.596 0 1.08-.484 1.08-1.08s-.483-1.08-1.08-1.08z"></Path></G></SVG>; +export const embedWordPressIcon = { + foreground: '#0073AA', + src: <SVG viewBox="0 0 24 24"><G><Path d="M12.158 12.786l-2.698 7.84c.806.236 1.657.365 2.54.365 1.047 0 2.05-.18 2.986-.51-.024-.037-.046-.078-.065-.123l-2.762-7.57zM3.008 12c0 3.56 2.07 6.634 5.068 8.092L3.788 8.342c-.5 1.117-.78 2.354-.78 3.658zm15.06-.454c0-1.112-.398-1.88-.74-2.48-.456-.74-.883-1.368-.883-2.11 0-.825.627-1.595 1.51-1.595.04 0 .078.006.116.008-1.598-1.464-3.73-2.36-6.07-2.36-3.14 0-5.904 1.613-7.512 4.053.21.008.41.012.58.012.94 0 2.395-.114 2.395-.114.484-.028.54.684.057.74 0 0-.487.058-1.03.086l3.275 9.74 1.968-5.902-1.4-3.838c-.485-.028-.944-.085-.944-.085-.486-.03-.43-.77.056-.742 0 0 1.484.114 2.368.114.94 0 2.397-.114 2.397-.114.486-.028.543.684.058.74 0 0-.488.058-1.03.086l3.25 9.665.897-2.997c.456-1.17.684-2.137.684-2.907zm1.82-3.86c.04.286.06.593.06.924 0 .912-.17 1.938-.683 3.22l-2.746 7.94c2.672-1.558 4.47-4.454 4.47-7.77 0-1.564-.4-3.033-1.1-4.314zM12 22C6.486 22 2 17.514 2 12S6.486 2 12 2s10 4.486 10 10-4.486 10-10 10z"></Path></G></SVG>, +}; +export const embedSpotifyIcon = { + foreground: '#1db954', + src: <SVG viewBox="0 0 24 24"><Path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2m4.586 14.424c-.18.295-.563.387-.857.207-2.35-1.434-5.305-1.76-8.786-.963-.335.077-.67-.133-.746-.47-.077-.334.132-.67.47-.745 3.808-.87 7.076-.496 9.712 1.115.293.18.386.563.206.857M17.81 13.7c-.226.367-.706.482-1.072.257-2.687-1.652-6.785-2.13-9.965-1.166-.413.127-.848-.106-.973-.517-.125-.413.108-.848.52-.973 3.632-1.102 8.147-.568 11.234 1.328.366.226.48.707.256 1.072m.105-2.835C14.692 8.95 9.375 8.775 6.297 9.71c-.493.15-1.016-.13-1.166-.624-.148-.495.13-1.017.625-1.167 3.532-1.073 9.404-.866 13.115 1.337.445.264.59.838.327 1.282-.264.443-.838.59-1.282.325" /></SVG>, +}; +export const embedFlickrIcon = <SVG viewBox="0 0 24 24"><Path d="m6.5 7c-2.75 0-5 2.25-5 5s2.25 5 5 5 5-2.25 5-5-2.25-5-5-5zm11 0c-2.75 0-5 2.25-5 5s2.25 5 5 5 5-2.25 5-5-2.25-5-5-5z" /></SVG>; +export const embedVimeoIcon = { + foreground: '#1ab7ea', + src: <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><G><Path d="M22.396 7.164c-.093 2.026-1.507 4.8-4.245 8.32C15.323 19.16 12.93 21 10.97 21c-1.214 0-2.24-1.12-3.08-3.36-.56-2.052-1.118-4.105-1.68-6.158-.622-2.24-1.29-3.36-2.004-3.36-.156 0-.7.328-1.634.98l-.978-1.26c1.027-.903 2.04-1.806 3.037-2.71C6 3.95 7.03 3.328 7.716 3.265c1.62-.156 2.616.95 2.99 3.32.404 2.558.685 4.148.84 4.77.468 2.12.982 3.18 1.543 3.18.435 0 1.09-.687 1.963-2.064.872-1.376 1.34-2.422 1.402-3.142.125-1.187-.343-1.782-1.4-1.782-.5 0-1.013.115-1.542.34 1.023-3.35 2.977-4.976 5.862-4.883 2.14.063 3.148 1.45 3.024 4.16z"></Path></G></SVG>, +}; +export const embedRedditIcon = <SVG viewBox="0 0 24 24"><Path d="M22 11.816c0-1.256-1.02-2.277-2.277-2.277-.593 0-1.122.24-1.526.613-1.48-.965-3.455-1.594-5.647-1.69l1.17-3.702 3.18.75c.01 1.027.847 1.86 1.877 1.86 1.035 0 1.877-.84 1.877-1.877 0-1.035-.842-1.877-1.877-1.877-.77 0-1.43.466-1.72 1.13L13.55 3.92c-.204-.047-.4.067-.46.26l-1.35 4.27c-2.317.037-4.412.67-5.97 1.67-.402-.355-.917-.58-1.493-.58C3.02 9.54 2 10.56 2 11.815c0 .814.433 1.523 1.078 1.925-.037.222-.06.445-.06.673 0 3.292 4.01 5.97 8.94 5.97s8.94-2.678 8.94-5.97c0-.214-.02-.424-.052-.632.687-.39 1.154-1.12 1.154-1.964zm-3.224-7.422c.606 0 1.1.493 1.1 1.1s-.493 1.1-1.1 1.1-1.1-.494-1.1-1.1.493-1.1 1.1-1.1zm-16 7.422c0-.827.673-1.5 1.5-1.5.313 0 .598.103.838.27-.85.675-1.477 1.478-1.812 2.36-.32-.274-.525-.676-.525-1.13zm9.183 7.79c-4.502 0-8.165-2.33-8.165-5.193S7.457 9.22 11.96 9.22s8.163 2.33 8.163 5.193-3.663 5.193-8.164 5.193zM20.635 13c-.326-.89-.948-1.7-1.797-2.383.247-.186.55-.3.882-.3.827 0 1.5.672 1.5 1.5 0 .482-.23.91-.586 1.184zm-11.64 1.704c-.76 0-1.397-.616-1.397-1.376 0-.76.636-1.397 1.396-1.397.76 0 1.376.638 1.376 1.398 0 .76-.616 1.376-1.376 1.376zm7.405-1.376c0 .76-.615 1.376-1.375 1.376s-1.4-.616-1.4-1.376c0-.76.64-1.397 1.4-1.397.76 0 1.376.638 1.376 1.398zm-1.17 3.38c.15.152.15.398 0 .55-.675.674-1.728 1.002-3.22 1.002l-.01-.002-.012.002c-1.492 0-2.544-.328-3.218-1.002-.152-.152-.152-.398 0-.55.152-.152.4-.15.55 0 .52.52 1.394.775 2.67.775l.01.002.01-.002c1.276 0 2.15-.253 2.67-.775.15-.152.398-.152.55 0z" /></SVG>; +export const embedTumbrIcon = { + foreground: '#35465c', + src: <SVG viewBox="0 0 24 24"><Path d="M19 3H5c-1.105 0-2 .895-2 2v14c0 1.105.895 2 2 2h14c1.105 0 2-.895 2-2V5c0-1.105-.895-2-2-2zm-5.57 14.265c-2.445.042-3.37-1.742-3.37-2.998V10.6H8.922V9.15c1.703-.615 2.113-2.15 2.21-3.026.006-.06.053-.084.08-.084h1.645V8.9h2.246v1.7H12.85v3.495c.008.476.182 1.13 1.08 1.107.3-.008.698-.094.907-.194l.54 1.6c-.205.297-1.12.642-1.946.657z" /></SVG>, +}; +export const embedAmazonIcon = <SVG viewBox="0 0 24 24"><Path d="M18.42 14.58c-.51-.66-1.05-1.23-1.05-2.5V7.87c0-1.8.15-3.45-1.2-4.68-1.05-1.02-2.79-1.35-4.14-1.35-2.6 0-5.52.96-6.12 4.14-.06.36.18.54.4.57l2.66.3c.24-.03.42-.27.48-.5.24-1.12 1.17-1.63 2.2-1.63.56 0 1.22.21 1.55.7.4.56.33 1.31.33 1.97v.36c-1.59.18-3.66.27-5.16.93a4.63 4.63 0 0 0-2.93 4.44c0 2.82 1.8 4.23 4.1 4.23 1.95 0 3.03-.45 4.53-1.98.51.72.66 1.08 1.59 1.83.18.09.45.09.63-.1v.04l2.1-1.8c.24-.21.2-.48.03-.75zm-5.4-1.2c-.45.75-1.14 1.23-1.92 1.23-1.05 0-1.65-.81-1.65-1.98 0-2.31 2.1-2.73 4.08-2.73v.6c0 1.05.03 1.92-.5 2.88z" /><Path d="M21.69 19.2a17.62 17.62 0 0 1-21.6-1.57c-.23-.2 0-.5.28-.33a23.88 23.88 0 0 0 20.93 1.3c.45-.19.84.3.39.6z" /><Path d="M22.8 17.96c-.36-.45-2.22-.2-3.1-.12-.23.03-.3-.18-.05-.36 1.5-1.05 3.96-.75 4.26-.39.3.36-.1 2.82-1.5 4.02-.21.18-.42.1-.3-.15.3-.8 1.02-2.58.69-3z" /></SVG>; diff --git a/gb-src/packages/block-library/src/embed/index.js b/gb-src/packages/block-library/src/embed/index.js new file mode 100644 index 0000000000000..7bf859a002373 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/index.js @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import { common as commonEmbeds, others as otherEmbeds } from './core-embeds'; +import { embedContentIcon } from './icons'; +import { getEmbedBlockSettings } from './settings'; + +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; + +export const name = 'core/embed'; + +export const settings = getEmbedBlockSettings( { + title: _x( 'Embed', 'block title' ), + description: __( 'Embed videos, images, tweets, audio, and other content from external sources.' ), + icon: embedContentIcon, + // Unknown embeds should not be responsive by default. + responsive: false, + transforms: { + from: [ + { + type: 'raw', + isMatch: ( node ) => node.nodeName === 'P' && /^\s*(https?:\/\/\S+)\s*$/i.test( node.textContent ), + transform: ( node ) => { + return createBlock( 'core/embed', { + url: node.textContent.trim(), + } ); + }, + }, + ], + }, +} ); + +export const common = commonEmbeds.map( + ( embedDefinition ) => { + return { + ...embedDefinition, + settings: getEmbedBlockSettings( embedDefinition.settings ), + }; + } +); + +export const others = otherEmbeds.map( + ( embedDefinition ) => { + return { + ...embedDefinition, + settings: getEmbedBlockSettings( embedDefinition.settings ), + }; + } +); diff --git a/gb-src/packages/block-library/src/embed/settings.js b/gb-src/packages/block-library/src/embed/settings.js new file mode 100644 index 0000000000000..17d97cee43da6 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/settings.js @@ -0,0 +1,141 @@ +/** + * Internal dependencies + */ +import { getEmbedEditComponent } from './edit'; + +/** + * External dependencies + */ +import classnames from 'classnames/dedupe'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; +import { RichText } from '@wordpress/block-editor'; +import { withSelect, withDispatch } from '@wordpress/data'; + +const embedAttributes = { + url: { + type: 'string', + }, + caption: { + type: 'string', + source: 'html', + selector: 'figcaption', + }, + type: { + type: 'string', + }, + providerNameSlug: { + type: 'string', + }, + allowResponsive: { + type: 'boolean', + default: true, + }, +}; + +export function getEmbedBlockSettings( { title, description, icon, category = 'embed', transforms, keywords = [], supports = {}, responsive = true } ) { + const blockDescription = description || __( 'Add a block that displays content pulled from other sites, like Twitter, Instagram or YouTube.' ); + const edit = getEmbedEditComponent( title, icon, responsive ); + return { + title, + description: blockDescription, + icon, + category, + keywords, + attributes: embedAttributes, + + supports: { + align: true, + ...supports, + }, + + transforms, + + edit: compose( + withSelect( ( select, ownProps ) => { + const { url } = ownProps.attributes; + const core = select( 'core' ); + const { getEmbedPreview, isPreviewEmbedFallback, isRequestingEmbedPreview, getThemeSupports } = core; + const preview = undefined !== url && getEmbedPreview( url ); + const previewIsFallback = undefined !== url && isPreviewEmbedFallback( url ); + const fetching = undefined !== url && isRequestingEmbedPreview( url ); + const themeSupports = getThemeSupports(); + // The external oEmbed provider does not exist. We got no type info and no html. + const badEmbedProvider = !! preview && undefined === preview.type && false === preview.html; + // Some WordPress URLs that can't be embedded will cause the API to return + // a valid JSON response with no HTML and `data.status` set to 404, rather + // than generating a fallback response as other embeds do. + const wordpressCantEmbed = !! preview && preview.data && preview.data.status === 404; + const validPreview = !! preview && ! badEmbedProvider && ! wordpressCantEmbed; + const cannotEmbed = undefined !== url && ( ! validPreview || previewIsFallback ); + return { + preview: validPreview ? preview : undefined, + fetching, + themeSupportsResponsive: themeSupports[ 'responsive-embeds' ], + cannotEmbed, + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { url } = ownProps.attributes; + const coreData = dispatch( 'core/data' ); + const tryAgain = () => { + coreData.invalidateResolution( 'core', 'getEmbedPreview', [ url ] ); + }; + return { + tryAgain, + }; + } ) + )( edit ), + + save( { attributes } ) { + const { url, caption, type, providerNameSlug } = attributes; + + if ( ! url ) { + return null; + } + + const embedClassName = classnames( 'wp-block-embed', { + [ `is-type-${ type }` ]: type, + [ `is-provider-${ providerNameSlug }` ]: providerNameSlug, + } ); + + return ( + <figure className={ embedClassName }> + <div className="wp-block-embed__wrapper"> + { `\n${ url }\n` /* URL needs to be on its own line. */ } + </div> + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); + }, + + deprecated: [ + { + attributes: embedAttributes, + save( { attributes } ) { + const { url, caption, type, providerNameSlug } = attributes; + + if ( ! url ) { + return null; + } + + const embedClassName = classnames( 'wp-block-embed', { + [ `is-type-${ type }` ]: type, + [ `is-provider-${ providerNameSlug }` ]: providerNameSlug, + } ); + + return ( + <figure className={ embedClassName }> + { `\n${ url }\n` /* URL needs to be on its own line. */ } + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); + }, + }, + ], + }; +} diff --git a/gb-src/packages/block-library/src/embed/style.scss b/gb-src/packages/block-library/src/embed/style.scss new file mode 100644 index 0000000000000..a3e9ee13f4d94 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/style.scss @@ -0,0 +1,80 @@ +// Apply max-width to floated items that have no intrinsic width +.block-editor-block-list__block[data-type="core/embed"][data-align="left"] .block-editor-block-list__block-edit, +.block-editor-block-list__block[data-type="core/embed"][data-align="right"] .block-editor-block-list__block-edit, +.wp-block-embed.alignleft, +.wp-block-embed.alignright { + // Instagram widgets have a min-width of 326px, so go a bit beyond that. + max-width: 360px; + width: 100%; +} + +.wp-block-embed { + // Supply caption styles to embeds, even if the theme hasn't opted in. + // Reason being: the new markup, figcaptions, are not likely to be styled in the majority of existing themes, + // so we supply the styles so as to not appear broken or unstyled in those. + figcaption { + @include caption-style(); + } + // The embed block is in a `figure` element, and many themes zero this out. + // This rule explicitly sets it, to ensure at least some bottom-margin in the flow. + margin-bottom: 1em; +} + +.wp-embed-responsive { + .wp-block-embed { + // Add responsiveness to common aspect ratios. + &.wp-embed-aspect-21-9 .wp-block-embed__wrapper, + &.wp-embed-aspect-18-9 .wp-block-embed__wrapper, + &.wp-embed-aspect-16-9 .wp-block-embed__wrapper, + &.wp-embed-aspect-4-3 .wp-block-embed__wrapper, + &.wp-embed-aspect-1-1 .wp-block-embed__wrapper, + &.wp-embed-aspect-9-16 .wp-block-embed__wrapper, + &.wp-embed-aspect-1-2 .wp-block-embed__wrapper { + position: relative; + + &::before { + content: ""; + display: block; + padding-top: 50%; // Default to 2:1 aspect ratio. + } + + iframe { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + } + } + + &.wp-embed-aspect-21-9 .wp-block-embed__wrapper::before { + padding-top: 42.85%; // 9 / 21 * 100 + } + + &.wp-embed-aspect-18-9 .wp-block-embed__wrapper::before { + padding-top: 50%; // 9 / 18 * 100 + } + + &.wp-embed-aspect-16-9 .wp-block-embed__wrapper::before { + padding-top: 56.25%; // 9 / 16 * 100 + } + + &.wp-embed-aspect-4-3 .wp-block-embed__wrapper::before { + padding-top: 75%; // 3 / 4 * 100 + } + + &.wp-embed-aspect-1-1 .wp-block-embed__wrapper::before { + padding-top: 100%; // 1 / 1 * 100 + } + + &.wp-embed-aspect-9-16 .wp-block-embed__wrapper::before { + padding-top: 177.78%; // 16 / 9 * 100 + } + + &.wp-embed-aspect-1-2 .wp-block-embed__wrapper::before { + padding-top: 200%; // 2 / 1 * 100 + } + } +} diff --git a/gb-src/packages/block-library/src/embed/test/__snapshots__/index.js.snap b/gb-src/packages/block-library/src/embed/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..78923aa24c5f6 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/test/__snapshots__/index.js.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`core/embed block edit matches snapshot 1`] = ` +<div + class="components-placeholder wp-block-embed" +> + <div + class="components-placeholder__label" + > + <span + class="editor-block-icon block-editor-block-icon has-colors" + > + <svg + aria-hidden="true" + class="dashicon dashicons-embed-generic" + focusable="false" + height="20" + role="img" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-3 6.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z" + /> + </svg> + </span> + Embed URL + </div> + <div + class="components-placeholder__instructions" + > + Paste a link to the content you want to display on your site. + </div> + <div + class="components-placeholder__fieldset" + > + <form> + <input + aria-label="Embed URL" + class="components-placeholder__input" + placeholder="Enter URL to embed here…" + type="url" + value="" + /> + <button + class="components-button is-button is-default is-large" + type="submit" + > + Embed + </button> + </form> + <div + class="components-placeholder__learn-more" + > + <a + class="components-external-link" + href="https://wordpress.org/support/article/embeds/" + rel="external noreferrer noopener" + target="_blank" + > + Learn more about embeds + <span + class="screen-reader-text" + > + (opens in a new tab) + </span> + <svg + aria-hidden="true" + class="dashicon dashicons-external components-external-link__icon" + focusable="false" + height="20" + role="img" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M9 3h8v8l-2-1V6.92l-5.6 5.59-1.41-1.41L14.08 5H10zm3 12v-3l2-2v7H3V6h8L9 8H5v7h7z" + /> + </svg> + </a> + </div> + </div> +</div> +`; diff --git a/gb-src/packages/block-library/src/embed/test/index.js b/gb-src/packages/block-library/src/embed/test/index.js new file mode 100644 index 0000000000000..5f01d8ff77ee8 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/test/index.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { render } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { getEmbedEditComponent } from '../edit'; +import { findBlock, getClassNames, createUpgradedEmbedBlock } from '../util'; + +describe( 'core/embed', () => { + test( 'block edit matches snapshot', () => { + const EmbedEdit = getEmbedEditComponent( 'Embed', 'embed-generic' ); + const wrapper = render( <EmbedEdit attributes={ {} } /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'findBlock matches a URL to a block name', () => { + const twitterURL = 'https://twitter.com/notnownikki'; + const youtubeURL = 'https://www.youtube.com/watch?v=bNnfuvC1LlU'; + const unknownURL = 'https://example.com/'; + + expect( findBlock( twitterURL ) ).toEqual( 'core-embed/twitter' ); + expect( findBlock( youtubeURL ) ).toEqual( 'core-embed/youtube' ); + expect( findBlock( unknownURL ) ).toEqual( 'core/embed' ); + } ); + + test( 'getClassNames returns aspect ratio class names for iframes with width and height', () => { + const html = '<iframe height="9" width="16"></iframe>'; + const expected = 'wp-embed-aspect-16-9 wp-has-aspect-ratio'; + expect( getClassNames( html ) ).toEqual( expected ); + } ); + + test( 'getClassNames does not return aspect ratio class names if we do not allow responsive', () => { + const html = '<iframe height="9" width="16"></iframe>'; + const expected = ''; + expect( getClassNames( html, '', false ) ).toEqual( expected ); + } ); + + test( 'getClassNames preserves exsiting class names when removing responsive classes', () => { + const html = '<iframe height="9" width="16"></iframe>'; + const expected = 'lovely'; + expect( getClassNames( html, 'lovely wp-embed-aspect-16-9 wp-has-aspect-ratio', false ) ).toEqual( expected ); + } ); + + test( 'createUpgradedEmbedBlock bails early when block type does not exist', () => { + const youtubeURL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + + expect( createUpgradedEmbedBlock( { attributes: { url: youtubeURL } }, {} ) ).toBeUndefined(); + } ); + + test( 'createUpgradedEmbedBlock returns a YouTube embed block when given a YouTube URL', () => { + const youtubeURL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + + registerBlockType( + 'core-embed/youtube', + { + title: 'YouTube', + category: 'embed', + } + ); + + const result = createUpgradedEmbedBlock( { attributes: { url: youtubeURL } }, {} ); + + unregisterBlockType( 'core-embed/youtube' ); + + expect( result ).not.toBeUndefined(); + expect( result.name ).toBe( 'core-embed/youtube' ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/embed/theme.scss b/gb-src/packages/block-library/src/embed/theme.scss new file mode 100644 index 0000000000000..a23337bcc55c1 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/theme.scss @@ -0,0 +1,3 @@ +.wp-block-embed figcaption { + @include caption-style-theme(); +} diff --git a/gb-src/packages/block-library/src/embed/util.js b/gb-src/packages/block-library/src/embed/util.js new file mode 100644 index 0000000000000..0096f1bc3cd98 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/util.js @@ -0,0 +1,222 @@ +/** + * Internal dependencies + */ +import { common, others } from './core-embeds'; +import { DEFAULT_EMBED_BLOCK, WORDPRESS_EMBED_BLOCK, ASPECT_RATIOS } from './constants'; + +/** + * External dependencies + */ +import { includes, kebabCase, toLower } from 'lodash'; +import classnames from 'classnames/dedupe'; +import memoize from 'memize'; + +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; +import { createBlock, getBlockType } from '@wordpress/blocks'; + +/** + * Returns true if any of the regular expressions match the URL. + * + * @param {string} url The URL to test. + * @param {Array} patterns The list of regular expressions to test agains. + * @return {boolean} True if any of the regular expressions match the URL. + */ +export const matchesPatterns = ( url, patterns = [] ) => { + return patterns.some( ( pattern ) => { + return url.match( pattern ); + } ); +}; + +/** + * Finds the block name that should be used for the URL, based on the + * structure of the URL. + * + * @param {string} url The URL to test. + * @return {string} The name of the block that should be used for this URL, e.g. core-embed/twitter + */ +export const findBlock = ( url ) => { + for ( const block of [ ...common, ...others ] ) { + if ( matchesPatterns( url, block.patterns ) ) { + return block.name; + } + } + return DEFAULT_EMBED_BLOCK; +}; + +export const isFromWordPress = ( html ) => { + return includes( html, 'class="wp-embedded-content"' ); +}; + +export const getPhotoHtml = ( photo ) => { + // 100% width for the preview so it fits nicely into the document, some "thumbnails" are + // actually the full size photo. If thumbnails not found, use full image. + const imageUrl = ( photo.thumbnail_url ) ? photo.thumbnail_url : photo.url; + const photoPreview = <p><img src={ imageUrl } alt={ photo.title } width="100%" /></p>; + return renderToString( photoPreview ); +}; + +/** + * Creates a more suitable embed block based on the passed in props + * and attributes generated from an embed block's preview. + * + * We require `attributesFromPreview` to be generated from the latest attributes + * and preview, and because of the way the react lifecycle operates, we can't + * guarantee that the attributes contained in the block's props are the latest + * versions, so we require that these are generated separately. + * See `getAttributesFromPreview` in the generated embed edit component. + * + * @param {Object} props The block's props. + * @param {Object} attributesFromPreview Attributes generated from the block's most up to date preview. + * @return {Object|undefined} A more suitable embed block if one exists. + */ +export const createUpgradedEmbedBlock = ( props, attributesFromPreview ) => { + const { preview, name } = props; + const { url } = props.attributes; + + if ( ! url ) { + return; + } + + const matchingBlock = findBlock( url ); + + if ( ! getBlockType( matchingBlock ) ) { + return; + } + + // WordPress blocks can work on multiple sites, and so don't have patterns, + // so if we're in a WordPress block, assume the user has chosen it for a WordPress URL. + if ( WORDPRESS_EMBED_BLOCK !== name && DEFAULT_EMBED_BLOCK !== matchingBlock ) { + // At this point, we have discovered a more suitable block for this url, so transform it. + if ( name !== matchingBlock ) { + return createBlock( matchingBlock, { url } ); + } + } + + if ( preview ) { + const { html } = preview; + + // We can't match the URL for WordPress embeds, we have to check the HTML instead. + if ( isFromWordPress( html ) ) { + // If this is not the WordPress embed block, transform it into one. + if ( WORDPRESS_EMBED_BLOCK !== name ) { + return createBlock( + WORDPRESS_EMBED_BLOCK, + { + url, + // By now we have the preview, but when the new block first renders, it + // won't have had all the attributes set, and so won't get the correct + // type and it won't render correctly. So, we pass through the current attributes + // here so that the initial render works when we switch to the WordPress + // block. This only affects the WordPress block because it can't be + // rendered in the usual Sandbox (it has a sandbox of its own) and it + // relies on the preview to set the correct render type. + ...attributesFromPreview, + } + ); + } + } + } +}; + +/** + * Returns class names with any relevant responsive aspect ratio names. + * + * @param {string} html The preview HTML that possibly contains an iframe with width and height set. + * @param {string} existingClassNames Any existing class names. + * @param {boolean} allowResponsive If the responsive class names should be added, or removed. + * @return {string} Deduped class names. + */ +export function getClassNames( html, existingClassNames = '', allowResponsive = true ) { + if ( ! allowResponsive ) { + // Remove all of the aspect ratio related class names. + const aspectRatioClassNames = { + 'wp-has-aspect-ratio': false, + }; + for ( let ratioIndex = 0; ratioIndex < ASPECT_RATIOS.length; ratioIndex++ ) { + const aspectRatioToRemove = ASPECT_RATIOS[ ratioIndex ]; + aspectRatioClassNames[ aspectRatioToRemove.className ] = false; + } + return classnames( + existingClassNames, + aspectRatioClassNames + ); + } + + const previewDocument = document.implementation.createHTMLDocument( '' ); + previewDocument.body.innerHTML = html; + const iframe = previewDocument.body.querySelector( 'iframe' ); + + // If we have a fixed aspect iframe, and it's a responsive embed block. + if ( iframe && iframe.height && iframe.width ) { + const aspectRatio = ( iframe.width / iframe.height ).toFixed( 2 ); + // Given the actual aspect ratio, find the widest ratio to support it. + for ( let ratioIndex = 0; ratioIndex < ASPECT_RATIOS.length; ratioIndex++ ) { + const potentialRatio = ASPECT_RATIOS[ ratioIndex ]; + if ( aspectRatio >= potentialRatio.ratio ) { + return classnames( + existingClassNames, + { + [ potentialRatio.className ]: allowResponsive, + 'wp-has-aspect-ratio': allowResponsive, + } + ); + } + } + } + + return existingClassNames; +} + +/** + * Fallback behaviour for unembeddable URLs. + * Creates a paragraph block containing a link to the URL, and calls `onReplace`. + * + * @param {string} url The URL that could not be embedded. + * @param {Function} onReplace Function to call with the created fallback block. + */ +export function fallback( url, onReplace ) { + const link = <a href={ url }>{ url }</a>; + onReplace( + createBlock( 'core/paragraph', { content: renderToString( link ) } ) + ); +} + +/*** + * Gets block attributes based on the preview and responsive state. + * + * @param {Object} preview The preview data. + * @param {string} title The block's title, e.g. Twitter. + * @param {Object} currentClassNames The block's current class names. + * @param {boolean} isResponsive Boolean indicating if the block supports responsive content. + * @param {boolean} allowResponsive Apply responsive classes to fixed size content. + * @return {Object} Attributes and values. + */ +export const getAttributesFromPreview = memoize( ( preview, title, currentClassNames, isResponsive, allowResponsive = true ) => { + if ( ! preview ) { + return {}; + } + + const attributes = {}; + // Some plugins only return HTML with no type info, so default this to 'rich'. + let { type = 'rich' } = preview; + // If we got a provider name from the API, use it for the slug, otherwise we use the title, + // because not all embed code gives us a provider name. + const { html, provider_name: providerName } = preview; + const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) ); + + if ( isFromWordPress( html ) ) { + type = 'wp-embed'; + } + + if ( html || 'photo' === type ) { + attributes.type = type; + attributes.providerNameSlug = providerNameSlug; + } + + attributes.className = getClassNames( html, currentClassNames, isResponsive && allowResponsive ); + + return attributes; +} ); diff --git a/gb-src/packages/block-library/src/embed/wp-embed-preview.js b/gb-src/packages/block-library/src/embed/wp-embed-preview.js new file mode 100644 index 0000000000000..ccfb0449819b8 --- /dev/null +++ b/gb-src/packages/block-library/src/embed/wp-embed-preview.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { withGlobalEvents } from '@wordpress/compose'; + +/** + * Browser dependencies + */ + +const { FocusEvent } = window; + +class WpEmbedPreview extends Component { + constructor() { + super( ...arguments ); + + this.checkFocus = this.checkFocus.bind( this ); + this.node = createRef(); + } + + /** + * Checks whether the wp embed iframe is the activeElement, + * if it is dispatch a focus event. + */ + checkFocus() { + const { activeElement } = document; + + if ( + activeElement.tagName !== 'IFRAME' || + activeElement.parentNode !== this.node.current + ) { + return; + } + + const focusEvent = new FocusEvent( 'focus', { bubbles: true } ); + activeElement.dispatchEvent( focusEvent ); + } + + render() { + const { html } = this.props; + return ( + <div + ref={ this.node } + className="wp-block-embed__wrapper" + dangerouslySetInnerHTML={ { __html: html } } + /> + ); + } +} + +export default withGlobalEvents( { + blur: 'checkFocus', +} )( WpEmbedPreview ); diff --git a/gb-src/packages/block-library/src/file/block.json b/gb-src/packages/block-library/src/file/block.json new file mode 100644 index 0000000000000..0c36a06aa9a55 --- /dev/null +++ b/gb-src/packages/block-library/src/file/block.json @@ -0,0 +1,38 @@ +{ + "name": "core/file", + "category": "common", + "attributes": { + "id": { + "type": "number" + }, + "href": { + "type": "string" + }, + "fileName": { + "type": "string", + "source": "html", + "selector": "a:not([download])" + }, + "textLinkHref": { + "type": "string", + "source": "attribute", + "selector": "a:not([download])", + "attribute": "href" + }, + "textLinkTarget": { + "type": "string", + "source": "attribute", + "selector": "a:not([download])", + "attribute": "target" + }, + "showDownloadButton": { + "type": "boolean", + "default": true + }, + "downloadButtonText": { + "type": "string", + "source": "html", + "selector": "a[download]" + } + } +} diff --git a/gb-src/packages/block-library/src/file/edit.js b/gb-src/packages/block-library/src/file/edit.js new file mode 100644 index 0000000000000..ad63137fa3416 --- /dev/null +++ b/gb-src/packages/block-library/src/file/edit.js @@ -0,0 +1,267 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + getBlobByURL, + isBlobURL, + revokeBlobURL, +} from '@wordpress/blob'; +import { + Animate, + ClipboardButton, + IconButton, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; +import { + BlockControls, + BlockIcon, + MediaUpload, + MediaUploadCheck, + MediaPlaceholder, + RichText, +} from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import icon from './icon'; +import FileBlockInspector from './inspector'; + +class FileEdit extends Component { + constructor() { + super( ...arguments ); + + this.onSelectFile = this.onSelectFile.bind( this ); + this.confirmCopyURL = this.confirmCopyURL.bind( this ); + this.resetCopyConfirmation = this.resetCopyConfirmation.bind( this ); + this.changeLinkDestinationOption = this.changeLinkDestinationOption.bind( this ); + this.changeOpenInNewWindow = this.changeOpenInNewWindow.bind( this ); + this.changeShowDownloadButton = this.changeShowDownloadButton.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + + this.state = { + hasError: false, + showCopyConfirmation: false, + }; + } + + componentDidMount() { + const { + attributes, + mediaUpload, + noticeOperations, + setAttributes, + } = this.props; + const { downloadButtonText, href } = attributes; + + // Upload a file drag-and-dropped into the editor + if ( isBlobURL( href ) ) { + const file = getBlobByURL( href ); + + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ media ] ) => this.onSelectFile( media ), + onError: ( message ) => { + this.setState( { hasError: true } ); + noticeOperations.createErrorNotice( message ); + }, + } ); + + revokeBlobURL( href ); + } + + if ( downloadButtonText === undefined ) { + setAttributes( { + downloadButtonText: _x( 'Download', 'button label' ), + } ); + } + } + + componentDidUpdate( prevProps ) { + // Reset copy confirmation state when block is deselected + if ( prevProps.isSelected && ! this.props.isSelected ) { + this.setState( { showCopyConfirmation: false } ); + } + } + + onSelectFile( media ) { + if ( media && media.url ) { + this.setState( { hasError: false } ); + this.props.setAttributes( { + href: media.url, + fileName: media.title, + textLinkHref: media.url, + id: media.id, + } ); + } + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + confirmCopyURL() { + this.setState( { showCopyConfirmation: true } ); + } + + resetCopyConfirmation() { + this.setState( { showCopyConfirmation: false } ); + } + + changeLinkDestinationOption( newHref ) { + // Choose Media File or Attachment Page (when file is in Media Library) + this.props.setAttributes( { textLinkHref: newHref } ); + } + + changeOpenInNewWindow( newValue ) { + this.props.setAttributes( { + textLinkTarget: newValue ? '_blank' : false, + } ); + } + + changeShowDownloadButton( newValue ) { + this.props.setAttributes( { showDownloadButton: newValue } ); + } + + render() { + const { + className, + isSelected, + attributes, + setAttributes, + noticeUI, + media, + } = this.props; + const { + fileName, + href, + textLinkHref, + textLinkTarget, + showDownloadButton, + downloadButtonText, + id, + } = attributes; + const { hasError, showCopyConfirmation } = this.state; + const attachmentPage = media && media.link; + + if ( ! href || hasError ) { + return ( + <MediaPlaceholder + icon={ <BlockIcon icon={ icon } /> } + labels={ { + title: __( 'File' ), + instructions: __( 'Upload a file or pick one from your media library.' ), + } } + onSelect={ this.onSelectFile } + notices={ noticeUI } + onError={ this.onUploadError } + accept="*" + /> + ); + } + + const classes = classnames( className, { + 'is-transient': isBlobURL( href ), + } ); + + return ( + <> + <FileBlockInspector + hrefs={ { href, textLinkHref, attachmentPage } } + { ...{ + openInNewWindow: !! textLinkTarget, + showDownloadButton, + changeLinkDestinationOption: this.changeLinkDestinationOption, + changeOpenInNewWindow: this.changeOpenInNewWindow, + changeShowDownloadButton: this.changeShowDownloadButton, + } } + /> + <BlockControls> + <MediaUploadCheck> + <Toolbar> + <MediaUpload + onSelect={ this.onSelectFile } + value={ id } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit file' ) } + onClick={ open } + icon="edit" + /> + ) } + /> + </Toolbar> + </MediaUploadCheck> + </BlockControls> + <Animate type={ isBlobURL( href ) ? 'loading' : null }> + { ( { className: animateClassName } ) => ( + <div className={ classnames( classes, animateClassName ) }> + <div className={ 'wp-block-file__content-wrapper' }> + <RichText + wrapperClassName={ 'wp-block-file__textlink' } + tagName="div" // must be block-level or else cursor disappears + value={ fileName } + placeholder={ __( 'Write file name…' ) } + withoutInteractiveFormatting + onChange={ ( text ) => setAttributes( { fileName: text } ) } + /> + { showDownloadButton && + <div className={ 'wp-block-file__button-richtext-wrapper' }> + { /* Using RichText here instead of PlainText so that it can be styled like a button */ } + <RichText + tagName="div" // must be block-level or else cursor disappears + className={ 'wp-block-file__button' } + value={ downloadButtonText } + withoutInteractiveFormatting + placeholder={ __( 'Add text…' ) } + onChange={ ( text ) => setAttributes( { downloadButtonText: text } ) } + /> + </div> + } + </div> + { isSelected && + <ClipboardButton + isDefault + text={ href } + className={ 'wp-block-file__copy-url-button' } + onCopy={ this.confirmCopyURL } + onFinishCopy={ this.resetCopyConfirmation } + disabled={ isBlobURL( href ) } + > + { showCopyConfirmation ? __( 'Copied!' ) : __( 'Copy URL' ) } + </ClipboardButton> + } + </div> + ) } + </Animate> + </> + ); + } +} + +export default compose( [ + withSelect( ( select, props ) => { + const { getMedia } = select( 'core' ); + const { getSettings } = select( 'core/block-editor' ); + const { __experimentalMediaUpload } = getSettings(); + const { id } = props.attributes; + return { + media: id === undefined ? undefined : getMedia( id ), + mediaUpload: __experimentalMediaUpload, + }; + } ), + withNotices, +] )( FileEdit ); diff --git a/gb-src/packages/block-library/src/file/editor.scss b/gb-src/packages/block-library/src/file/editor.scss new file mode 100644 index 0000000000000..f24c18d3b4fd9 --- /dev/null +++ b/gb-src/packages/block-library/src/file/editor.scss @@ -0,0 +1,28 @@ +.wp-block-file { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; + + .wp-block-file__content-wrapper { + flex-grow: 1; + } + + .wp-block-file__textlink { + display: inline-block; + min-width: 1em; + + &:focus { + box-shadow: none; + } + } + + .wp-block-file__button-richtext-wrapper { + display: inline-block; + margin-left: 0.75em; + } + + .wp-block-file__copy-url-button { + margin-left: 1em; + } +} diff --git a/gb-src/packages/block-library/src/file/icon.js b/gb-src/packages/block-library/src/file/icon.js new file mode 100644 index 0000000000000..2ee9e4867b270 --- /dev/null +++ b/gb-src/packages/block-library/src/file/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M9.17 6l2 2H20v10H4V6h5.17M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" /></SVG>; diff --git a/gb-src/packages/block-library/src/file/index.js b/gb-src/packages/block-library/src/file/index.js new file mode 100644 index 0000000000000..86efc4291d56f --- /dev/null +++ b/gb-src/packages/block-library/src/file/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'File' ), + description: __( 'Add a link to a downloadable file.' ), + icon, + keywords: [ __( 'document' ), __( 'pdf' ) ], + supports: { + align: true, + }, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/file/inspector.js b/gb-src/packages/block-library/src/file/inspector.js new file mode 100644 index 0000000000000..f76ff53929d38 --- /dev/null +++ b/gb-src/packages/block-library/src/file/inspector.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + PanelBody, + SelectControl, + ToggleControl, +} from '@wordpress/components'; +import { InspectorControls } from '@wordpress/block-editor'; + +export default function FileBlockInspector( { + hrefs, + openInNewWindow, + showDownloadButton, + changeLinkDestinationOption, + changeOpenInNewWindow, + changeShowDownloadButton, +} ) { + const { href, textLinkHref, attachmentPage } = hrefs; + + let linkDestinationOptions = [ { value: href, label: __( 'URL' ) } ]; + if ( attachmentPage ) { + linkDestinationOptions = [ + { value: href, label: __( 'Media File' ) }, + { value: attachmentPage, label: __( 'Attachment page' ) }, + ]; + } + + return ( + <> + <InspectorControls> + <PanelBody title={ __( 'Text link settings' ) }> + <SelectControl + label={ __( 'Link To' ) } + value={ textLinkHref } + options={ linkDestinationOptions } + onChange={ changeLinkDestinationOption } + /> + <ToggleControl + label={ __( 'Open in new tab' ) } + checked={ openInNewWindow } + onChange={ changeOpenInNewWindow } + /> + </PanelBody> + <PanelBody title={ __( 'Download button settings' ) }> + <ToggleControl + label={ __( 'Show download button' ) } + checked={ showDownloadButton } + onChange={ changeShowDownloadButton } + /> + </PanelBody> + </InspectorControls> + </> + ); +} diff --git a/gb-src/packages/block-library/src/file/save.js b/gb-src/packages/block-library/src/file/save.js new file mode 100644 index 0000000000000..461018e4bb8a7 --- /dev/null +++ b/gb-src/packages/block-library/src/file/save.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + href, + fileName, + textLinkHref, + textLinkTarget, + showDownloadButton, + downloadButtonText, + } = attributes; + + return ( href && + <div> + { ! RichText.isEmpty( fileName ) && + <a + href={ textLinkHref } + target={ textLinkTarget } + rel={ textLinkTarget ? 'noreferrer noopener' : false } + > + <RichText.Content + value={ fileName } + /> + </a> + } + { showDownloadButton && + <a + href={ href } + className="wp-block-file__button" + download={ true } + > + <RichText.Content + value={ downloadButtonText } + /> + </a> + } + </div> + ); +} diff --git a/gb-src/packages/block-library/src/file/style.scss b/gb-src/packages/block-library/src/file/style.scss new file mode 100644 index 0000000000000..61cf2ad25ec1b --- /dev/null +++ b/gb-src/packages/block-library/src/file/style.scss @@ -0,0 +1,38 @@ +.wp-block-file { + margin-bottom: 1.5em; + + &.aligncenter { + text-align: center; + } + + &.alignright { + /*rtl:ignore*/ + text-align: right; + } + + .wp-block-file__button { + background: $dark-gray-700; + border-radius: 2em; + color: $white; + font-size: $default-font-size; + padding: 0.5em 1em; + } + + a.wp-block-file__button { + text-decoration: none; + + &:hover, + &:visited, + &:focus, + &:active { + box-shadow: none; + color: $white; + opacity: 0.85; + text-decoration: none; + } + } + + * + .wp-block-file__button { + margin-left: 0.75em; + } +} diff --git a/gb-src/packages/block-library/src/file/transforms.js b/gb-src/packages/block-library/src/file/transforms.js new file mode 100644 index 0000000000000..a37aedfb45fce --- /dev/null +++ b/gb-src/packages/block-library/src/file/transforms.js @@ -0,0 +1,138 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createBlobURL } from '@wordpress/blob'; +import { createBlock } from '@wordpress/blocks'; +import { select } from '@wordpress/data'; + +const transforms = { + from: [ + { + type: 'files', + isMatch( files ) { + return files.length > 0; + }, + // We define a lower priorty (higher number) than the default of 10. This + // ensures that the File block is only created as a fallback. + priority: 15, + transform: ( files ) => { + const blocks = []; + + files.forEach( ( file ) => { + const blobURL = createBlobURL( file ); + + // File will be uploaded in componentDidMount() + blocks.push( createBlock( 'core/file', { + href: blobURL, + fileName: file.name, + textLinkHref: blobURL, + } ) ); + } ); + + return blocks; + }, + }, + { + type: 'block', + blocks: [ 'core/audio' ], + transform: ( attributes ) => { + return createBlock( 'core/file', { + href: attributes.src, + fileName: attributes.caption, + textLinkHref: attributes.src, + id: attributes.id, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/video' ], + transform: ( attributes ) => { + return createBlock( 'core/file', { + href: attributes.src, + fileName: attributes.caption, + textLinkHref: attributes.src, + id: attributes.id, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( attributes ) => { + return createBlock( 'core/file', { + href: attributes.url, + fileName: attributes.caption, + textLinkHref: attributes.url, + id: attributes.id, + } ); + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/audio' ], + isMatch: ( { id } ) => { + if ( ! id ) { + return false; + } + const { getMedia } = select( 'core' ); + const media = getMedia( id ); + return !! media && includes( media.mime_type, 'audio' ); + }, + transform: ( attributes ) => { + return createBlock( 'core/audio', { + src: attributes.href, + caption: attributes.fileName, + id: attributes.id, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/video' ], + isMatch: ( { id } ) => { + if ( ! id ) { + return false; + } + const { getMedia } = select( 'core' ); + const media = getMedia( id ); + return !! media && includes( media.mime_type, 'video' ); + }, + transform: ( attributes ) => { + return createBlock( 'core/video', { + src: attributes.href, + caption: attributes.fileName, + id: attributes.id, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/image' ], + isMatch: ( { id } ) => { + if ( ! id ) { + return false; + } + const { getMedia } = select( 'core' ); + const media = getMedia( id ); + return !! media && includes( media.mime_type, 'image' ); + }, + transform: ( attributes ) => { + return createBlock( 'core/image', { + url: attributes.href, + caption: attributes.fileName, + id: attributes.id, + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/gallery/block.json b/gb-src/packages/block-library/src/gallery/block.json new file mode 100644 index 0000000000000..342aafd4b2c49 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/block.json @@ -0,0 +1,65 @@ +{ + "name": "core/gallery", + "category": "common", + "attributes": { + "images": { + "type": "array", + "default": [ ], + "source": "query", + "selector": ".blocks-gallery-item", + "query": { + "url": { + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "fullUrl": { + "source": "attribute", + "selector": "img", + "attribute": "data-full-url" + }, + "link": { + "source": "attribute", + "selector": "img", + "attribute": "data-link" + }, + "alt": { + "source": "attribute", + "selector": "img", + "attribute": "alt", + "default": "" + }, + "id": { + "source": "attribute", + "selector": "img", + "attribute": "data-id" + }, + "caption": { + "type": "string", + "source": "html", + "selector": ".blocks-gallery-item__caption" + } + } + }, + "ids": { + "type": "array", + "default": [ ] + }, + "columns": { + "type": "number" + }, + "caption": { + "type": "string", + "source": "html", + "selector": ".blocks-gallery-caption" + }, + "imageCrop": { + "type": "boolean", + "default": true + }, + "linkTo": { + "type": "string", + "default": "none" + } + } +} diff --git a/gb-src/packages/block-library/src/gallery/deprecated.js b/gb-src/packages/block-library/src/gallery/deprecated.js new file mode 100644 index 0000000000000..74af7e3f50b6e --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/deprecated.js @@ -0,0 +1,297 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { map, some } from 'lodash'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { defaultColumnsNumber } from './shared'; + +const deprecated = [ + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: 'ul.wp-block-gallery .blocks-gallery-item', + query: { + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + fullUrl: { + source: 'attribute', + selector: 'img', + attribute: 'data-full-url', + }, + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + link: { + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + caption: { + type: 'array', + source: 'children', + selector: 'figcaption', + }, + }, + }, + ids: { + type: 'array', + default: [], + }, + columns: { + type: 'number', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, + }, + save( { attributes } ) { + const { images, columns = defaultColumnsNumber( attributes ), imageCrop, linkTo } = attributes; + return ( + <ul className={ `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }` } > + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case 'media': + href = image.fullUrl || image.url; + break; + case 'attachment': + href = image.link; + break; + } + + const img = ( + <img + src={ image.url } + alt={ image.alt } + data-id={ image.id } + data-full-url={ image.fullUrl } + data-link={ image.link } + className={ image.id ? `wp-image-${ image.id }` : null } + /> + ); + + return ( + <li key={ image.id || image.url } className="blocks-gallery-item"> + <figure> + { href ? <a href={ href }>{ img }</a> : img } + { image.caption && image.caption.length > 0 && ( + <RichText.Content tagName="figcaption" value={ image.caption } /> + ) } + </figure> + </li> + ); + } ) } + </ul> + ); + }, + }, + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: 'ul.wp-block-gallery .blocks-gallery-item', + query: { + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + link: { + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + caption: { + type: 'array', + source: 'children', + selector: 'figcaption', + }, + }, + }, + columns: { + type: 'number', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, + }, + isEligible( { images, ids } ) { + return images && + images.length > 0 && + ( + ( ! ids && images ) || + ( ids && images && ids.length !== images.length ) || + some( images, ( id, index ) => { + if ( ! id && ids[ index ] !== null ) { + return true; + } + return parseInt( id, 10 ) !== ids[ index ]; + } ) + ); + }, + migrate( attributes ) { + return { + ...attributes, + ids: map( attributes.images, ( { id } ) => { + if ( ! id ) { + return null; + } + return parseInt( id, 10 ); + } ), + }; + }, + save( { attributes } ) { + const { images, columns = defaultColumnsNumber( attributes ), imageCrop, linkTo } = attributes; + return ( + <ul className={ `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }` } > + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case 'media': + href = image.url; + break; + case 'attachment': + href = image.link; + break; + } + + const img = <img src={ image.url } alt={ image.alt } data-id={ image.id } data-link={ image.link } className={ image.id ? `wp-image-${ image.id }` : null } />; + + return ( + <li key={ image.id || image.url } className="blocks-gallery-item"> + <figure> + { href ? <a href={ href }>{ img }</a> : img } + { image.caption && image.caption.length > 0 && ( + <RichText.Content tagName="figcaption" value={ image.caption } /> + ) } + </figure> + </li> + ); + } ) } + </ul> + ); + }, + }, + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: 'div.wp-block-gallery figure.blocks-gallery-image img', + query: { + url: { + source: 'attribute', + attribute: 'src', + }, + alt: { + source: 'attribute', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + attribute: 'data-id', + }, + }, + }, + columns: { + type: 'number', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, + align: { + type: 'string', + default: 'none', + }, + }, + + save( { attributes } ) { + const { images, columns = defaultColumnsNumber( attributes ), align, imageCrop, linkTo } = attributes; + const className = classnames( `columns-${ columns }`, { + alignnone: align === 'none', + 'is-cropped': imageCrop, + } ); + return ( + <div className={ className } > + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case 'media': + href = image.url; + break; + case 'attachment': + href = image.link; + break; + } + + const img = <img src={ image.url } alt={ image.alt } data-id={ image.id } />; + + return ( + <figure key={ image.id || image.url } className="blocks-gallery-image"> + { href ? <a href={ href }>{ img }</a> : img } + </figure> + ); + } ) } + </div> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/gallery/edit.js b/gb-src/packages/block-library/src/gallery/edit.js new file mode 100644 index 0000000000000..fc6a6cd841628 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/edit.js @@ -0,0 +1,391 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { + every, + filter, + find, + forEach, + map, + some, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { + PanelBody, + RangeControl, + SelectControl, + ToggleControl, + withNotices, +} from '@wordpress/components'; +import { + BlockIcon, + MediaPlaceholder, + InspectorControls, + RichText, +} from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import GalleryImage from './gallery-image'; +import { icon } from './icons'; +import { defaultColumnsNumber, pickRelevantMediaFiles } from './shared'; + +const MAX_COLUMNS = 8; +const linkOptions = [ + { value: 'attachment', label: __( 'Attachment Page' ) }, + { value: 'media', label: __( 'Media File' ) }, + { value: 'none', label: __( 'None' ) }, +]; +const ALLOWED_MEDIA_TYPES = [ 'image' ]; + +class GalleryEdit extends Component { + constructor() { + super( ...arguments ); + + this.onSelectImage = this.onSelectImage.bind( this ); + this.onSelectImages = this.onSelectImages.bind( this ); + this.setLinkTo = this.setLinkTo.bind( this ); + this.setColumnsNumber = this.setColumnsNumber.bind( this ); + this.toggleImageCrop = this.toggleImageCrop.bind( this ); + this.onMove = this.onMove.bind( this ); + this.onMoveForward = this.onMoveForward.bind( this ); + this.onMoveBackward = this.onMoveBackward.bind( this ); + this.onRemoveImage = this.onRemoveImage.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + this.setImageAttributes = this.setImageAttributes.bind( this ); + this.setAttributes = this.setAttributes.bind( this ); + this.onFocusGalleryCaption = this.onFocusGalleryCaption.bind( this ); + + this.state = { + selectedImage: null, + attachmentCaptions: null, + }; + } + + setAttributes( attributes ) { + if ( attributes.ids ) { + throw new Error( 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes' ); + } + + if ( attributes.images ) { + attributes = { + ...attributes, + ids: map( attributes.images, 'id' ), + }; + } + + this.props.setAttributes( attributes ); + } + + onSelectImage( index ) { + return () => { + if ( this.state.selectedImage !== index ) { + this.setState( { + selectedImage: index, + } ); + } + }; + } + + onMove( oldIndex, newIndex ) { + const images = [ ...this.props.attributes.images ]; + images.splice( newIndex, 1, this.props.attributes.images[ oldIndex ] ); + images.splice( oldIndex, 1, this.props.attributes.images[ newIndex ] ); + this.setState( { selectedImage: newIndex } ); + this.setAttributes( { images } ); + } + + onMoveForward( oldIndex ) { + return () => { + if ( oldIndex === this.props.attributes.images.length - 1 ) { + return; + } + this.onMove( oldIndex, oldIndex + 1 ); + }; + } + + onMoveBackward( oldIndex ) { + return () => { + if ( oldIndex === 0 ) { + return; + } + this.onMove( oldIndex, oldIndex - 1 ); + }; + } + + onRemoveImage( index ) { + return () => { + const images = filter( this.props.attributes.images, ( img, i ) => index !== i ); + const { columns } = this.props.attributes; + this.setState( { selectedImage: null } ); + this.setAttributes( { + images, + columns: columns ? Math.min( images.length, columns ) : columns, + } ); + }; + } + + selectCaption( newImage, images, attachmentCaptions ) { + const currentImage = find( + images, { id: newImage.id } + ); + + const currentImageCaption = currentImage ? currentImage.caption : newImage.caption; + + if ( ! attachmentCaptions ) { + return currentImageCaption; + } + + const attachment = find( + attachmentCaptions, { id: newImage.id } + ); + + // if the attachment caption is updated + if ( attachment && ( attachment.caption !== newImage.caption ) ) { + return newImage.caption; + } + + return currentImageCaption; + } + + onSelectImages( newImages ) { + const { columns, images } = this.props.attributes; + const { attachmentCaptions } = this.state; + this.setState( + { + attachmentCaptions: newImages.map( ( newImage ) => ( { + id: newImage.id, + caption: newImage.caption, + } ) ), + } + ); + this.setAttributes( { + images: newImages.map( ( newImage ) => ( { + ...pickRelevantMediaFiles( newImage ), + caption: this.selectCaption( newImage, images, attachmentCaptions ), + } ) ), + columns: columns ? Math.min( newImages.length, columns ) : columns, + } ); + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + setLinkTo( value ) { + this.setAttributes( { linkTo: value } ); + } + + setColumnsNumber( value ) { + this.setAttributes( { columns: value } ); + } + + toggleImageCrop() { + this.setAttributes( { imageCrop: ! this.props.attributes.imageCrop } ); + } + + getImageCropHelp( checked ) { + return checked ? __( 'Thumbnails are cropped to align.' ) : __( 'Thumbnails are not cropped.' ); + } + + onFocusGalleryCaption() { + this.setState( { + selectedImage: null, + } ); + } + + setImageAttributes( index, attributes ) { + const { attributes: { images } } = this.props; + const { setAttributes } = this; + if ( ! images[ index ] ) { + return; + } + setAttributes( { + images: [ + ...images.slice( 0, index ), + { + ...images[ index ], + ...attributes, + }, + ...images.slice( index + 1 ), + ], + } ); + } + + componentDidMount() { + const { attributes, mediaUpload } = this.props; + const { images } = attributes; + if ( every( images, ( { url } ) => isBlobURL( url ) ) ) { + const filesList = map( images, ( { url } ) => getBlobByURL( url ) ); + forEach( images, ( { url } ) => revokeBlobURL( url ) ); + mediaUpload( { + filesList, + onFileChange: this.onSelectImages, + allowedTypes: [ 'image' ], + } ); + } + } + + componentDidUpdate( prevProps ) { + // Deselect images when deselecting the block + if ( ! this.props.isSelected && prevProps.isSelected ) { + this.setState( { + selectedImage: null, + captionSelected: false, + } ); + } + } + + render() { + const { + attributes, + className, + isSelected, + noticeUI, + setAttributes, + } = this.props; + const { + align, + columns = defaultColumnsNumber( attributes ), + caption, + imageCrop, + images, + linkTo, + } = attributes; + + const hasImages = !! images.length; + const hasImagesWithId = hasImages && some( images, ( { id } ) => id ); + + const mediaPlaceholder = ( + <MediaPlaceholder + addToGallery={ hasImagesWithId } + isAppender={ hasImages } + className={ className } + dropZoneUIOnly={ hasImages && ! isSelected } + icon={ ! hasImages && <BlockIcon icon={ icon } /> } + labels={ { + title: ! hasImages && __( 'Gallery' ), + instructions: ! hasImages && __( 'Drag images, upload new ones or select files from your library.' ), + } } + onSelect={ this.onSelectImages } + accept="image/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + multiple + value={ hasImagesWithId ? images : undefined } + onError={ this.onUploadError } + notices={ hasImages ? undefined : noticeUI } + /> + ); + + if ( ! hasImages ) { + return mediaPlaceholder; + } + + const captionClassNames = classnames( + 'blocks-gallery-caption', + { + 'screen-reader-text': ! isSelected && RichText.isEmpty( caption ), + } + ); + return ( + <> + <InspectorControls> + <PanelBody title={ __( 'Gallery Settings' ) }> + { images.length > 1 && <RangeControl + label={ __( 'Columns' ) } + value={ columns } + onChange={ this.setColumnsNumber } + min={ 1 } + max={ Math.min( MAX_COLUMNS, images.length ) } + required + /> } + <ToggleControl + label={ __( 'Crop Images' ) } + checked={ !! imageCrop } + onChange={ this.toggleImageCrop } + help={ this.getImageCropHelp } + /> + <SelectControl + label={ __( 'Link To' ) } + value={ linkTo } + onChange={ this.setLinkTo } + options={ linkOptions } + /> + </PanelBody> + </InspectorControls> + { noticeUI } + <figure className={ classnames( + className, + { + [ `align${ align }` ]: align, + [ `columns-${ columns }` ]: columns, + 'is-cropped': imageCrop, + } + ) } + > + <ul className="blocks-gallery-grid"> + { images.map( ( img, index ) => { + /* translators: %1$d is the order number of the image, %2$d is the total number of images. */ + const ariaLabel = sprintf( __( 'image %1$d of %2$d in gallery' ), ( index + 1 ), images.length ); + + return ( + <li className="blocks-gallery-item" key={ img.id || img.url }> + <GalleryImage + url={ img.url } + alt={ img.alt } + id={ img.id } + isFirstItem={ index === 0 } + isLastItem={ ( index + 1 ) === images.length } + isSelected={ isSelected && this.state.selectedImage === index } + onMoveBackward={ this.onMoveBackward( index ) } + onMoveForward={ this.onMoveForward( index ) } + onRemove={ this.onRemoveImage( index ) } + onSelect={ this.onSelectImage( index ) } + setAttributes={ ( attrs ) => this.setImageAttributes( index, attrs ) } + caption={ img.caption } + aria-label={ ariaLabel } + /> + </li> + ); + } ) } + </ul> + { mediaPlaceholder } + <RichText + tagName="figcaption" + className={ captionClassNames } + placeholder={ __( 'Write gallery caption…' ) } + value={ caption } + unstableOnFocus={ this.onFocusGalleryCaption } + onChange={ ( value ) => setAttributes( { caption: value } ) } + inlineToolbar + /> + </figure> + </> + ); + } +} +export default compose( [ + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + const { + __experimentalMediaUpload, + } = getSettings(); + + return { + mediaUpload: __experimentalMediaUpload, + }; + } ), + withNotices, +] )( GalleryEdit ); diff --git a/gb-src/packages/block-library/src/gallery/editor.scss b/gb-src/packages/block-library/src/gallery/editor.scss new file mode 100644 index 0000000000000..35dae3316117f --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/editor.scss @@ -0,0 +1,158 @@ +.wp-block-gallery { + // Override the default list style type _only in the editor_ + // to avoid :not() selector specificity issues. + // See https://github.com/WordPress/gutenberg/pull/10358 + li { + list-style-type: none; + } + + // Override the bottom margin every block gets. + .is-selected & { + margin-bottom: 0; + } +} + +figure.wp-block-gallery { + display: block; + margin: 0; +} + +// need to override default editor ul styles +.blocks-gallery-grid.blocks-gallery-grid { + margin-bottom: 0; +} + +.blocks-gallery-item { + + // Hide the focus outline that otherwise briefly appears when selecting a block. + figure:not(.is-selected):focus { + outline: none; + } + + figure.is-selected { + outline: 4px solid theme(primary); + } + + figure.is-transient img { + opacity: 0.3; + } + + .block-editor-rich-text { + position: absolute; + bottom: 0; + width: 100%; + max-height: 100%; + overflow-y: auto; + } + + .is-selected .block-editor-rich-text { + // IE calculates this incorrectly, so leave it to modern browsers. + @supports (position: sticky) { + right: 0; + left: 0; + margin-top: -4px; + } + + // Override negative margins so this toolbar isn't hidden by overflow. Overflow is needed for long captions. + .block-editor-rich-text__inline-toolbar { + top: 0; + } + + // Make extra space for the inline toolbar. + figcaption { + padding-top: 48px; + } + } + + .is-selected .block-library-gallery-item__move-menu, + .is-selected .block-library-gallery-item__inline-menu { + background: $white; + border: 1px solid $dark-opacity-light-800; + border-radius: $radius-round-rectangle; + transition: box-shadow 0.2s ease-out; + @include reduce-motion("transition"); + + &:hover { + box-shadow: $shadow-toolbar; + } + + .components-button { + color: $dark-opacity-300; + padding: 2px; + height: $icon-button-size-small; + + // Remove hover box shadows, since they clash with the container. + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + } + + @include break-small() { + // Use smaller buttons to fit when there are many columns. + .columns-7 &, + .columns-8 & { + padding: 0; + width: inherit; + height: inherit; + } + } + } + + .components-button:focus { + color: inherit; + } + } + + .block-editor-rich-text figcaption { + position: relative; + overflow: hidden; + + a { + color: $white; + } + } +} + +.block-library-gallery-item__move-menu, +.block-library-gallery-item__inline-menu { + margin: $grid-size; + display: inline-flex; + z-index: z-index(".block-library-gallery-item__inline-menu"); + + .components-button { + color: transparent; + } + + @include break-small() { + // Use smaller buttons to fit when there are many columns. + .columns-7 &, + .columns-8 & { + padding: $grid-size-small / 2; + } + } +} + +.block-library-gallery-item__inline-menu { + position: absolute; + top: -2px; + right: -2px; +} + +.block-library-gallery-item__move-menu { + position: absolute; + top: -2px; + left: -2px; +} + +.blocks-gallery-item__move-backward, +.blocks-gallery-item__move-forward, +.blocks-gallery-item__remove { + padding: 0; +} + +.blocks-gallery-item .components-spinner { + position: absolute; + top: 50%; + left: 50%; + margin-top: -9px; + margin-left: -9px; +} diff --git a/gb-src/packages/block-library/src/gallery/gallery-image.js b/gb-src/packages/block-library/src/gallery/gallery-image.js new file mode 100644 index 0000000000000..aa88c05484637 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/gallery-image.js @@ -0,0 +1,186 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { IconButton, Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { withSelect } from '@wordpress/data'; +import { RichText } from '@wordpress/block-editor'; +import { isBlobURL } from '@wordpress/blob'; + +/** + * Internal dependencies + */ +import { leftArrow, rightArrow } from './icons'; + +class GalleryImage extends Component { + constructor() { + super( ...arguments ); + + this.onSelectImage = this.onSelectImage.bind( this ); + this.onSelectCaption = this.onSelectCaption.bind( this ); + this.onRemoveImage = this.onRemoveImage.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + + this.state = { + captionSelected: false, + }; + } + + bindContainer( ref ) { + this.container = ref; + } + + onSelectCaption() { + if ( ! this.state.captionSelected ) { + this.setState( { + captionSelected: true, + } ); + } + + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + } + + onSelectImage() { + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + + if ( this.state.captionSelected ) { + this.setState( { + captionSelected: false, + } ); + } + } + + onRemoveImage( event ) { + if ( + this.container === document.activeElement && + this.props.isSelected && [ BACKSPACE, DELETE ].indexOf( event.keyCode ) !== -1 + ) { + event.stopPropagation(); + event.preventDefault(); + this.props.onRemove(); + } + } + + componentDidUpdate( prevProps ) { + const { isSelected, image, url } = this.props; + if ( image && ! url ) { + this.props.setAttributes( { + url: image.source_url, + alt: image.alt_text, + } ); + } + + // unselect the caption so when the user selects other image and comeback + // the caption is not immediately selected + if ( this.state.captionSelected && ! isSelected && prevProps.isSelected ) { + this.setState( { + captionSelected: false, + } ); + } + } + + render() { + const { url, alt, id, linkTo, link, isFirstItem, isLastItem, isSelected, caption, onRemove, onMoveForward, onMoveBackward, setAttributes, 'aria-label': ariaLabel } = this.props; + + let href; + + switch ( linkTo ) { + case 'media': + href = url; + break; + case 'attachment': + href = link; + break; + } + + const img = ( + // Disable reason: Image itself is not meant to be interactive, but should + // direct image selection and unfocus caption fields. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <> + <img + src={ url } + alt={ alt } + data-id={ id } + onClick={ this.onSelectImage } + onFocus={ this.onSelectImage } + onKeyDown={ this.onRemoveImage } + tabIndex="0" + aria-label={ ariaLabel } + ref={ this.bindContainer } + /> + { isBlobURL( url ) && <Spinner /> } + </> + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); + + const className = classnames( { + 'is-selected': isSelected, + 'is-transient': isBlobURL( url ), + } ); + + return ( + <figure className={ className }> + { href ? <a href={ href }>{ img }</a> : img } + <div className="block-library-gallery-item__move-menu"> + <IconButton + icon={ leftArrow } + onClick={ isFirstItem ? undefined : onMoveBackward } + className="blocks-gallery-item__move-backward" + label={ __( 'Move image backward' ) } + aria-disabled={ isFirstItem } + disabled={ ! isSelected } + /> + <IconButton + icon={ rightArrow } + onClick={ isLastItem ? undefined : onMoveForward } + className="blocks-gallery-item__move-forward" + label={ __( 'Move image forward' ) } + aria-disabled={ isLastItem } + disabled={ ! isSelected } + /> + </div> + <div className="block-library-gallery-item__inline-menu"> + <IconButton + icon="no-alt" + onClick={ onRemove } + className="blocks-gallery-item__remove" + label={ __( 'Remove image' ) } + disabled={ ! isSelected } + /> + </div> + { ( isSelected || caption ) && ( + <RichText + tagName="figcaption" + placeholder={ isSelected ? __( 'Write caption…' ) : null } + value={ caption } + isSelected={ this.state.captionSelected } + onChange={ ( newCaption ) => setAttributes( { caption: newCaption } ) } + unstableOnFocus={ this.onSelectCaption } + inlineToolbar + /> + ) } + </figure> + ); + } +} + +export default withSelect( ( select, ownProps ) => { + const { getMedia } = select( 'core' ); + const { id } = ownProps; + + return { + image: id ? getMedia( id ) : null, + }; +} )( GalleryImage ); diff --git a/gb-src/packages/block-library/src/gallery/icons.js b/gb-src/packages/block-library/src/gallery/icons.js new file mode 100644 index 0000000000000..3847732258b78 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/icons.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export const icon = ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <G><Path d="M20 4v12H8V4h12m0-2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 9.67l1.69 2.26 2.48-3.1L19 15H9zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6H2z" /></G> + </SVG> +); + +export const leftArrow = ( + <SVG width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"> + <Path d="M5 8.70002L10.6 14.4L12 12.9L7.8 8.70002L12 4.50002L10.6 3.00002L5 8.70002Z" /> + </SVG> +); + +export const rightArrow = ( + <SVG width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"> + <Path d="M13 8.7L7.4 3L6 4.5L10.2 8.7L6 12.9L7.4 14.4L13 8.7Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/gallery/index.js b/gb-src/packages/block-library/src/gallery/index.js new file mode 100644 index 0000000000000..bfeed76dc1a47 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/index.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import { icon } from './icons'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Gallery' ), + description: __( 'Display multiple images in a rich gallery.' ), + icon, + keywords: [ __( 'images' ), __( 'photos' ) ], + example: { + attributes: { + columns: 2, + images: [ + { url: 'https://s.w.org/images/core/5.3/Glacial_lakes%2C_Bhutan.jpg' }, + { url: 'https://s.w.org/images/core/5.3/Sediment_off_the_Yucatan_Peninsula.jpg' }, + ], + }, + }, + supports: { + align: true, + }, + transforms, + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/gallery/save.js b/gb-src/packages/block-library/src/gallery/save.js new file mode 100644 index 0000000000000..9981c028598b7 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/save.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { defaultColumnsNumber } from './shared'; + +export default function save( { attributes } ) { + const { images, columns = defaultColumnsNumber( attributes ), imageCrop, caption, linkTo } = attributes; + + return ( + <figure className={ `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }` }> + <ul className="blocks-gallery-grid"> + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case 'media': + href = image.fullUrl || image.url; + break; + case 'attachment': + href = image.link; + break; + } + + const img = ( + <img + src={ image.url } + alt={ image.alt } + data-id={ image.id } + data-full-url={ image.fullUrl } + data-link={ image.link } + className={ image.id ? `wp-image-${ image.id }` : null } + /> + ); + + return ( + <li key={ image.id || image.url } className="blocks-gallery-item"> + <figure> + { href ? <a href={ href }>{ img }</a> : img } + { ! RichText.isEmpty( image.caption ) && ( + <RichText.Content tagName="figcaption" className="blocks-gallery-item__caption" value={ image.caption } /> + ) } + </figure> + </li> + ); + } ) } + </ul> + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" className="blocks-gallery-caption" value={ caption } /> } + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/gallery/shared.js b/gb-src/packages/block-library/src/gallery/shared.js new file mode 100644 index 0000000000000..7d40f3b90d62d --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/shared.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { get, pick } from 'lodash'; + +export function defaultColumnsNumber( attributes ) { + return Math.min( 3, attributes.images.length ); +} + +export const pickRelevantMediaFiles = ( image ) => { + const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); + imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url; + const fullUrl = get( image, [ 'sizes', 'full', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'full', 'source_url' ] ); + if ( fullUrl ) { + imageProps.fullUrl = fullUrl; + } + return imageProps; +}; diff --git a/gb-src/packages/block-library/src/gallery/style.scss b/gb-src/packages/block-library/src/gallery/style.scss new file mode 100644 index 0000000000000..92c85c9620114 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/style.scss @@ -0,0 +1,146 @@ +.wp-block-gallery, +.blocks-gallery-grid { + display: flex; + flex-wrap: wrap; + list-style-type: none; + padding: 0; + // Some themes give all <ul> default margin instead of padding. + margin: 0; + + .blocks-gallery-image, + .blocks-gallery-item { + // Add space between thumbnails, and unset right most thumbnails later. + margin: 0 $grid-size-large $grid-size-large 0; + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: center; + position: relative; + + figure { + margin: 0; + height: 100%; + + // IE doesn't support flex so omit that. + @supports (position: sticky) { + display: flex; + align-items: flex-end; + justify-content: flex-start; + } + } + + img { + display: block; + max-width: 100%; + height: auto; + } + + // IE doesn't handle cropping, so we need an explicit width here. + img { + width: 100%; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + width: auto; + } + } + + figcaption { + position: absolute; + bottom: 0; + width: 100%; + max-height: 100%; + overflow: auto; + padding: 40px 10px 9px; + color: $white; + text-align: center; + font-size: $default-font-size; + background: linear-gradient(0deg, rgba($color: $black, $alpha: 0.7) 0, rgba($color: $black, $alpha: 0.3) 70%, transparent); + + img { + display: inline; + } + } + } + + // Cropped + &.is-cropped .blocks-gallery-image, + &.is-cropped .blocks-gallery-item { + a, + img { + // IE11 doesn't support object-fit, so just make sure images aren't skewed. + // The following rules are for all browsers. + width: 100%; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + height: 100%; + flex: 1; + object-fit: cover; + } + } + } + + // On mobile and responsive viewports, we allow only 1 or 2 columns at the most. + & .blocks-gallery-image, + & .blocks-gallery-item { + width: calc((100% - #{ $grid-size-large }) / 2); + + &:nth-of-type(even) { + margin-right: 0; + } + } + + &.columns-1 .blocks-gallery-image, + &.columns-1 .blocks-gallery-item { + width: 100%; + margin-right: 0; + } + + // Beyond mobile viewports, we allow up to 8 columns. + @include break-small { + @for $i from 3 through 8 { + &.columns-#{ $i } .blocks-gallery-image, + &.columns-#{ $i } .blocks-gallery-item { + width: calc((100% - #{ $grid-size-large } * #{ $i - 1 }) / #{ $i }); + margin-right: 16px; + + // Rules inside this query are only run by Microsoft Edge. + // Edge miscalculates `calc`, so we have to add some buffer. + // See also https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/15637241/ + @supports (-ms-ime-align:auto) { + width: calc((100% - #{ $grid-size-large } * #{ $i - 1 }) / #{ $i } - 1px); + } + } + } + + // Unset the right margin on every rightmost gallery item to ensure center balance. + @for $column-count from 1 through 8 { + &.columns-#{ $column-count } .blocks-gallery-image:nth-of-type(#{ $column-count }n), + &.columns-#{ $column-count } .blocks-gallery-item:nth-of-type(#{ $column-count }n) { + margin-right: 0; + } + } + } + + // Last item always needs margins reset. + .blocks-gallery-image:last-child, + .blocks-gallery-item:last-child { + margin-right: 0; + } + + // Apply max-width to floated items that have no intrinsic width. + &.alignleft, + &.alignright { + max-width: $content-width / 2; + width: 100%; + } + + // If the gallery is centered, center the content inside as well. + &.aligncenter { + .blocks-gallery-item figure { + justify-content: center; + } + } +} + diff --git a/gb-src/packages/block-library/src/gallery/theme.scss b/gb-src/packages/block-library/src/gallery/theme.scss new file mode 100644 index 0000000000000..8c5047510f53a --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/theme.scss @@ -0,0 +1,3 @@ +.blocks-gallery-caption { + @include caption-style-theme(); +} diff --git a/gb-src/packages/block-library/src/gallery/transforms.js b/gb-src/packages/block-library/src/gallery/transforms.js new file mode 100644 index 0000000000000..8e42a109ee4b6 --- /dev/null +++ b/gb-src/packages/block-library/src/gallery/transforms.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { filter, every } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { createBlobURL } from '@wordpress/blob'; + +/** + * Internal dependencies + */ +import { pickRelevantMediaFiles } from './shared'; + +const parseShortcodeIds = ( ids ) => { + if ( ! ids ) { + return []; + } + + return ids.split( ',' ).map( ( id ) => ( + parseInt( id, 10 ) + ) ); +}; + +const transforms = { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ 'core/image' ], + transform: ( attributes ) => { + // Init the align attribute from the first item which may be either the placeholder or an image. + let { align } = attributes[ 0 ]; + // Loop through all the images and check if they have the same align. + align = every( attributes, [ 'align', align ] ) ? align : undefined; + + const validImages = filter( attributes, ( { url } ) => url ); + + return createBlock( 'core/gallery', { + images: validImages.map( ( { id, url, alt, caption } ) => ( { + id, + url, + alt, + caption, + } ) ), + ids: validImages.map( ( { id } ) => id ), + align, + } ); + }, + }, + { + type: 'shortcode', + tag: 'gallery', + attributes: { + images: { + type: 'array', + shortcode: ( { named: { ids } } ) => { + return parseShortcodeIds( ids ).map( ( id ) => ( { + id, + } ) ); + }, + }, + ids: { + type: 'array', + shortcode: ( { named: { ids } } ) => { + return parseShortcodeIds( ids ); + }, + }, + columns: { + type: 'number', + shortcode: ( { named: { columns = '3' } } ) => { + return parseInt( columns, 10 ); + }, + }, + linkTo: { + type: 'string', + shortcode: ( { named: { link = 'attachment' } } ) => { + return link === 'file' ? 'media' : link; + }, + }, + }, + }, + { + // When created by drag and dropping multiple files on an insertion point + type: 'files', + isMatch( files ) { + return files.length !== 1 && every( files, ( file ) => file.type.indexOf( 'image/' ) === 0 ); + }, + transform( files ) { + const block = createBlock( 'core/gallery', { + images: files.map( ( file ) => pickRelevantMediaFiles( { + url: createBlobURL( file ), + } ) ), + } ); + return block; + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( { images, align } ) => { + if ( images.length > 0 ) { + return images.map( ( { id, url, alt, caption } ) => createBlock( 'core/image', { + id, + url, + alt, + caption, + align, + } ) ); + } + return createBlock( 'core/image', { align } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/group/block.json b/gb-src/packages/block-library/src/group/block.json new file mode 100644 index 0000000000000..d8b417099f8fd --- /dev/null +++ b/gb-src/packages/block-library/src/group/block.json @@ -0,0 +1,12 @@ +{ + "name": "core/group", + "category": "layout", + "attributes": { + "backgroundColor": { + "type": "string" + }, + "customBackgroundColor": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/group/deprecated.js b/gb-src/packages/block-library/src/group/deprecated.js new file mode 100644 index 0000000000000..ced2cfca58b99 --- /dev/null +++ b/gb-src/packages/block-library/src/group/deprecated.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { InnerBlocks, getColorClassName } from '@wordpress/block-editor'; + +const deprecated = [ + // v1 of group block. Deprecated to add an inner-container div around `InnerBlocks.Content`. + { + attributes: { + backgroundColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + }, + supports: { + align: [ 'wide', 'full' ], + anchor: true, + html: false, + }, + save( { attributes } ) { + const { backgroundColor, customBackgroundColor } = attributes; + + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const className = classnames( backgroundClass, { + 'has-background': backgroundColor || customBackgroundColor, + } ); + + const styles = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + }; + + return ( + <div className={ className } style={ styles }> + <InnerBlocks.Content /> + </div> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/group/edit.js b/gb-src/packages/block-library/src/group/edit.js new file mode 100644 index 0000000000000..1e7445a20cc95 --- /dev/null +++ b/gb-src/packages/block-library/src/group/edit.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { + InspectorControls, + InnerBlocks, + PanelColorSettings, + withColors, +} from '@wordpress/block-editor'; + +function GroupEdit( { + className, + setBackgroundColor, + backgroundColor, + hasInnerBlocks, +} ) { + const styles = { + backgroundColor: backgroundColor.color, + }; + + const classes = classnames( className, backgroundColor.class, { + 'has-background': !! backgroundColor.color, + } ); + + return ( + <> + <InspectorControls> + <PanelColorSettings + title={ __( 'Color Settings' ) } + colorSettings={ [ + { + value: backgroundColor.color, + onChange: setBackgroundColor, + label: __( 'Background Color' ), + }, + ] } + /> + </InspectorControls> + <div className={ classes } style={ styles }> + <div className="wp-block-group__inner-container"> + <InnerBlocks + renderAppender={ ! hasInnerBlocks && InnerBlocks.ButtonBlockAppender } + /> + </div> + </div> + </> + ); +} + +export default compose( [ + withColors( 'backgroundColor' ), + withSelect( ( select, { clientId } ) => { + const { + getBlock, + } = select( 'core/block-editor' ); + + const block = getBlock( clientId ); + + return { + hasInnerBlocks: !! ( block && block.innerBlocks.length ), + }; + } ), +] )( GroupEdit ); diff --git a/gb-src/packages/block-library/src/group/editor.scss b/gb-src/packages/block-library/src/group/editor.scss new file mode 100644 index 0000000000000..166d3992c6daf --- /dev/null +++ b/gb-src/packages/block-library/src/group/editor.scss @@ -0,0 +1,115 @@ +/** + * Group: All Alignment Settings + */ +.wp-block[data-type="core/group"] { + + // Ensure not rendering outside the element + // as -1px causes overflow-x scrollbars + .editor-block-list__insertion-point { + left: 0; + right: 0; + } + + // Only applied when background is added to cancel out padding + > .editor-block-list__block-edit > div > .wp-block-group.has-background > .wp-block-group__inner-container > .editor-inner-blocks { + margin-top: -#{$block-padding*2 + $block-spacing}; + margin-bottom: -#{$block-padding*2 + $block-spacing}; + } + + // Full Width Blocks + // specificity required to only target immediate child Blocks of a Group + > .editor-block-list__block-edit > div > .wp-block-group > .wp-block-group__inner-container > .editor-inner-blocks > .editor-block-list__layout > .wp-block[data-align="full"] { + margin-left: auto; + margin-right: auto; + padding-left: $block-padding*2; + padding-right: $block-padding*2; + + @include break-small() { + padding-left: $block-padding*4 + $block-spacing/2; // 58px + padding-right: $block-padding*4 + $block-spacing/2; // 58px + } + } + + // Full Width Blocks with a background (ie: has padding) + > .editor-block-list__block-edit > div > .wp-block-group.has-background > .wp-block-group__inner-container > .editor-inner-blocks > .editor-block-list__layout > .wp-block[data-align="full"] { + // note: using position `left` causes hoz scrollbars so + // we opt to use margin instead + // the 30px matches the hoz padding applied in `theme.scss` + // added when the Block has a background set + margin-left: -30px; + + // 60px here is x2 the hoz padding from `theme.scss` added when + // the Block has a background set + // note: also duplicated below for full width Groups + width: calc(100% + 60px); + } +} + +/** + * Group: Full Width Alignment + */ +.wp-block[data-type="core/group"][data-align="full"] { + + // First tier of InnerBlocks must act like the container of the standard canvas + > .editor-block-list__block-edit > div > .wp-block-group > .wp-block-group__inner-container > .editor-inner-blocks { + margin-left: auto; + margin-right: auto; + padding-left: 0; + padding-right: 0; + + > .editor-block-list__layout { + margin-left: 0; + margin-right: 0; + } + } + + // Full Width Blocks + // specificity required to only target immediate child Blocks of Group + > .editor-block-list__block-edit > div > .wp-block-group > .wp-block-group__inner-container > .editor-inner-blocks > .editor-block-list__layout > .wp-block[data-align="full"] { + padding-right: 0; + padding-left: 0; + left: 0; + width: 100%; + max-width: none; + + // Allow to be flush with the edges of the canvas + > .editor-block-list__block-edit { + margin-left: 0; + margin-right: 0; + } + } + + // Full Width Blocks with a background (ie: has padding) + // note: also duplicated above for all Group widths + > .editor-block-list__block-edit > div > .wp-block-group.has-background > .wp-block-group__inner-container > .editor-inner-blocks > .editor-block-list__layout > .wp-block[data-align="full"] { + width: calc(100% + 60px); + } +} + +// Add padding when the block is selected, for easier interaction. +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/group"].has-child-selected > .block-editor-block-list__block-edit > [data-block] > .wp-block-group, +.block-editor-block-list__layout .block-editor-block-list__block[data-type="core/group"].is-selected > .block-editor-block-list__block-edit > [data-block] > .wp-block-group { + + > .wp-block-group__inner-container > .block-editor-inner-blocks { + padding: $block-padding; + } + + &:not(.has-background) > .wp-block-group__inner-container > .block-editor-inner-blocks > .block-editor-block-list__layout { + margin-top: -$block-padding * 2; + margin-bottom: -$block-padding * 2; + } +} + +// Place block list appender in the same place content will appear. +[data-type="core/group"].is-selected { + + .block-list-appender { + margin-left: 0; + margin-right: 0; + } + + .has-background .block-list-appender { + margin-top: $block-padding + $grid-size-small; + margin-bottom: $block-padding + $grid-size-small; + } +} diff --git a/gb-src/packages/block-library/src/group/icon.js b/gb-src/packages/block-library/src/group/icon.js new file mode 100644 index 0000000000000..f9d9dc7790ba9 --- /dev/null +++ b/gb-src/packages/block-library/src/group/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fillRule="evenodd" clipRule="evenodd" d="M9 8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-1v3a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h1V8zm2 3h4V9h-4v2zm2 2H9v2h4v-2z" /><Path fillRule="evenodd" clipRule="evenodd" d="M2 4.732A2 2 0 1 1 4.732 2h14.536A2 2 0 1 1 22 4.732v14.536A2 2 0 1 1 19.268 22H4.732A2 2 0 1 1 2 19.268V4.732zM4.732 4h14.536c.175.304.428.557.732.732v14.536a2.01 2.01 0 0 0-.732.732H4.732A2.01 2.01 0 0 0 4 19.268V4.732A2.01 2.01 0 0 0 4.732 4z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/group/index.js b/gb-src/packages/block-library/src/group/index.js new file mode 100644 index 0000000000000..d3bcfb86dca11 --- /dev/null +++ b/gb-src/packages/block-library/src/group/index.js @@ -0,0 +1,126 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Group' ), + icon, + description: __( 'A block that groups other blocks.' ), + keywords: [ __( 'container' ), __( 'wrapper' ), __( 'row' ), __( 'section' ) ], + example: { + attributes: { + customBackgroundColor: '#ffffff', + }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + customTextColor: '#cf2e2e', + fontSize: 'large', + content: __( 'One.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + customTextColor: '#ff6900', + fontSize: 'large', + content: __( 'Two.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + customTextColor: '#fcb900', + fontSize: 'large', + content: __( 'Three.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + customTextColor: '#00d084', + fontSize: 'large', + content: __( 'Four.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + customTextColor: '#0693e3', + fontSize: 'large', + content: __( 'Five.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + customTextColor: '#9b51e0', + fontSize: 'large', + content: __( 'Six.' ), + }, + }, + ], + }, + supports: { + align: [ 'wide', 'full' ], + anchor: true, + html: false, + }, + transforms: { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ '*' ], + __experimentalConvert( blocks ) { + // Avoid transforming a single `core/group` Block + if ( blocks.length === 1 && blocks[ 0 ].name === 'core/group' ) { + return; + } + + const alignments = [ 'wide', 'full' ]; + + // Determine the widest setting of all the blocks to be grouped + const widestAlignment = blocks.reduce( ( result, block ) => { + const { align } = block.attributes; + return alignments.indexOf( align ) > alignments.indexOf( result ) ? align : result; + }, undefined ); + + // Clone the Blocks to be Grouped + // Failing to create new block references causes the original blocks + // to be replaced in the switchToBlockType call thereby meaning they + // are removed both from their original location and within the + // new group block. + const groupInnerBlocks = blocks.map( ( block ) => { + return createBlock( block.name, block.attributes, block.innerBlocks ); + } ); + + return createBlock( 'core/group', { + align: widestAlignment, + }, groupInnerBlocks ); + }, + }, + + ], + }, + + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/group/save.js b/gb-src/packages/block-library/src/group/save.js new file mode 100644 index 0000000000000..c2c56221766b1 --- /dev/null +++ b/gb-src/packages/block-library/src/group/save.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { InnerBlocks, getColorClassName } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { backgroundColor, customBackgroundColor } = attributes; + + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const className = classnames( backgroundClass, { + 'has-background': backgroundColor || customBackgroundColor, + } ); + + const styles = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + }; + + return ( + <div className={ className } style={ styles }> + <div className="wp-block-group__inner-container"> + <InnerBlocks.Content /> + </div> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/group/theme.scss b/gb-src/packages/block-library/src/group/theme.scss new file mode 100644 index 0000000000000..61e711c3aaa2f --- /dev/null +++ b/gb-src/packages/block-library/src/group/theme.scss @@ -0,0 +1,9 @@ +.wp-block-group { + &.has-background { + // Matches paragraph Block padding + // Todo: normalise with variables + padding: 20px 30px; + margin-top: 0; + margin-bottom: 0; + } +} diff --git a/gb-src/packages/block-library/src/heading/block.json b/gb-src/packages/block-library/src/heading/block.json new file mode 100644 index 0000000000000..2c47b2c709652 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/block.json @@ -0,0 +1,28 @@ +{ + "name": "core/heading", + "category": "common", + "attributes": { + "align": { + "type": "string" + }, + "content": { + "type": "string", + "source": "html", + "selector": "h1,h2,h3,h4,h5,h6", + "default": "" + }, + "level": { + "type": "number", + "default": 2 + }, + "placeholder": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "customTextColor": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/heading/deprecated.js b/gb-src/packages/block-library/src/heading/deprecated.js new file mode 100644 index 0000000000000..e38ffb56171e8 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/deprecated.js @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, + RichText, +} from '@wordpress/block-editor'; + +const blockSupports = { + className: false, + anchor: true, +}; + +const blockAttributes = { + align: { + type: 'string', + }, + content: { + type: 'string', + source: 'html', + selector: 'h1,h2,h3,h4,h5,h6', + default: '', + }, + level: { + type: 'number', + default: 2, + }, + placeholder: { + type: 'string', + }, + textColor: { + type: 'string', + }, + customTextColor: { + type: 'string', + }, +}; + +const deprecated = [ + { + attributes: blockAttributes, + save( { attributes } ) { + const { + align, + content, + customTextColor, + level, + textColor, + } = attributes; + const tagName = 'h' + level; + + const textClass = getColorClassName( 'color', textColor ); + + const className = classnames( { + [ textClass ]: textClass, + [ `has-text-align-${ align }` ]: align, + } ); + + return ( + <RichText.Content + className={ className ? className : undefined } + tagName={ tagName } + style={ { + color: textClass ? undefined : customTextColor, + } } + value={ content } + /> + ); + }, + supports: blockSupports, + }, + { + supports: blockSupports, + attributes: blockAttributes, + save( { attributes } ) { + const { + align, + level, + content, + textColor, + customTextColor, + } = attributes; + const tagName = 'h' + level; + + const textClass = getColorClassName( 'color', textColor ); + + const className = classnames( { + [ textClass ]: textClass, + } ); + + return ( + <RichText.Content + className={ className ? className : undefined } + tagName={ tagName } + style={ { + textAlign: align, + color: textClass ? undefined : customTextColor, + } } + value={ content } + /> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/heading/edit.js b/gb-src/packages/block-library/src/heading/edit.js new file mode 100644 index 0000000000000..3289fb5a7d247 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/edit.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import HeadingToolbar from './heading-toolbar'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { PanelBody } from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { createBlock } from '@wordpress/blocks'; +import { + AlignmentToolbar, + BlockControls, + InspectorControls, + RichText, + withColors, + PanelColorSettings, +} from '@wordpress/block-editor'; +import { memo } from '@wordpress/element'; + +const HeadingColorUI = memo( + function( { + textColorValue, + setTextColor, + } ) { + return ( + <PanelColorSettings + title={ __( 'Color Settings' ) } + initialOpen={ false } + colorSettings={ [ + { + value: textColorValue, + onChange: setTextColor, + label: __( 'Text Color' ), + }, + ] } + /> + ); + } +); + +function HeadingEdit( { + attributes, + setAttributes, + mergeBlocks, + onReplace, + className, + textColor, + setTextColor, +} ) { + const { align, content, level, placeholder } = attributes; + const tagName = 'h' + level; + + return ( + <> + <BlockControls> + <HeadingToolbar minLevel={ 2 } maxLevel={ 5 } selectedLevel={ level } onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } /> + <AlignmentToolbar value={ align } onChange={ ( nextAlign ) => { + setAttributes( { align: nextAlign } ); + } } /> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Heading Settings' ) }> + <p>{ __( 'Level' ) }</p> + <HeadingToolbar isCollapsed={ false } minLevel={ 1 } maxLevel={ 7 } selectedLevel={ level } onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } /> + </PanelBody> + <HeadingColorUI + setTextColor={ setTextColor } + textColorValue={ textColor.color } + /> + </InspectorControls> + <RichText + identifier="content" + wrapperClassName="wp-block-heading" + tagName={ tagName } + value={ content } + onChange={ ( value ) => setAttributes( { content: value } ) } + onMerge={ mergeBlocks } + onSplit={ ( value ) => { + if ( ! value ) { + return createBlock( 'core/paragraph' ); + } + + return createBlock( 'core/heading', { + ...attributes, + content: value, + } ); + } } + onReplace={ onReplace } + onRemove={ () => onReplace( [] ) } + className={ classnames( className, { + [ `has-text-align-${ align }` ]: align, + 'has-text-color': textColor.color, + [ textColor.class ]: textColor.class, + } ) } + placeholder={ placeholder || __( 'Write heading…' ) } + style={ { + color: textColor.color, + } } + /> + </> + ); +} + +export default compose( [ + withColors( 'backgroundColor', { textColor: 'color' } ), +] )( HeadingEdit ); diff --git a/gb-src/packages/block-library/src/heading/edit.native.js b/gb-src/packages/block-library/src/heading/edit.native.js new file mode 100644 index 0000000000000..389791663b054 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/edit.native.js @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import HeadingToolbar from './heading-toolbar'; +import styles from './editor.scss'; + +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { RichText, BlockControls } from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; + +const HeadingEdit = ( { + attributes, + mergeBlocks, + onFocus, + onReplace, + setAttributes, + style, +} ) => ( + <View onAccessibilityTap={ onFocus }> + <BlockControls> + <HeadingToolbar + minLevel={ 2 } + maxLevel={ 5 } + selectedLevel={ attributes.level } + onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } + /> + </BlockControls> + <RichText + identifier="content" + tagName={ 'h' + attributes.level } + value={ attributes.content } + style={ { + ...style, + minHeight: styles[ 'wp-block-heading' ].minHeight, + } } + onChange={ ( value ) => setAttributes( { content: value } ) } + onMerge={ mergeBlocks } + onSplit={ ( value ) => { + if ( ! value ) { + return createBlock( 'core/paragraph' ); + } + + return createBlock( 'core/heading', { + ...attributes, + content: value, + } ); + } } + onReplace={ onReplace } + onRemove={ () => onReplace( [] ) } + placeholder={ attributes.placeholder || __( 'Write heading…' ) } + /> + </View> +); + +export default HeadingEdit; diff --git a/gb-src/packages/block-library/src/heading/editor.native.scss b/gb-src/packages/block-library/src/heading/editor.native.scss new file mode 100644 index 0000000000000..9518b3bdbacbb --- /dev/null +++ b/gb-src/packages/block-library/src/heading/editor.native.scss @@ -0,0 +1,4 @@ + +.wp-block-heading { + min-height: $min-height-heading; +} diff --git a/gb-src/packages/block-library/src/heading/heading-level-icon.js b/gb-src/packages/block-library/src/heading/heading-level-icon.js new file mode 100644 index 0000000000000..a65d9f4a0ecb6 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/heading-level-icon.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default function HeadingLevelIcon( { level } ) { + const levelToPath = { + 1: 'M9 5h2v10H9v-4H5v4H3V5h2v4h4V5zm6.6 0c-.6.9-1.5 1.7-2.6 2v1h2v7h2V5h-1.4z', + 2: 'M7 5h2v10H7v-4H3v4H1V5h2v4h4V5zm8 8c.5-.4.6-.6 1.1-1.1.4-.4.8-.8 1.2-1.3.3-.4.6-.8.9-1.3.2-.4.3-.8.3-1.3 0-.4-.1-.9-.3-1.3-.2-.4-.4-.7-.8-1-.3-.3-.7-.5-1.2-.6-.5-.2-1-.2-1.5-.2-.4 0-.7 0-1.1.1-.3.1-.7.2-1 .3-.3.1-.6.3-.9.5-.3.2-.6.4-.8.7l1.2 1.2c.3-.3.6-.5 1-.7.4-.2.7-.3 1.2-.3s.9.1 1.3.4c.3.3.5.7.5 1.1 0 .4-.1.8-.4 1.1-.3.5-.6.9-1 1.2-.4.4-1 .9-1.6 1.4-.6.5-1.4 1.1-2.2 1.6V15h8v-2H15z', + 3: 'M12.1 12.2c.4.3.8.5 1.2.7.4.2.9.3 1.4.3.5 0 1-.1 1.4-.3.3-.1.5-.5.5-.8 0-.2 0-.4-.1-.6-.1-.2-.3-.3-.5-.4-.3-.1-.7-.2-1-.3-.5-.1-1-.1-1.5-.1V9.1c.7.1 1.5-.1 2.2-.4.4-.2.6-.5.6-.9 0-.3-.1-.6-.4-.8-.3-.2-.7-.3-1.1-.3-.4 0-.8.1-1.1.3-.4.2-.7.4-1.1.6l-1.2-1.4c.5-.4 1.1-.7 1.6-.9.5-.2 1.2-.3 1.8-.3.5 0 1 .1 1.6.2.4.1.8.3 1.2.5.3.2.6.5.8.8.2.3.3.7.3 1.1 0 .5-.2.9-.5 1.3-.4.4-.9.7-1.5.9v.1c.6.1 1.2.4 1.6.8.4.4.7.9.7 1.5 0 .4-.1.8-.3 1.2-.2.4-.5.7-.9.9-.4.3-.9.4-1.3.5-.5.1-1 .2-1.6.2-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1l1.1-1.4zM7 9H3V5H1v10h2v-4h4v4h2V5H7v4z', + 4: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm10-2h-1v2h-2v-2h-5v-2l4-6h3v6h1v2zm-3-2V7l-2.8 4H16z', + 5: 'M12.1 12.2c.4.3.7.5 1.1.7.4.2.9.3 1.3.3.5 0 1-.1 1.4-.4.4-.3.6-.7.6-1.1 0-.4-.2-.9-.6-1.1-.4-.3-.9-.4-1.4-.4H14c-.1 0-.3 0-.4.1l-.4.1-.5.2-1-.6.3-5h6.4v1.9h-4.3L14 8.8c.2-.1.5-.1.7-.2.2 0 .5-.1.7-.1.5 0 .9.1 1.4.2.4.1.8.3 1.1.6.3.2.6.6.8.9.2.4.3.9.3 1.4 0 .5-.1 1-.3 1.4-.2.4-.5.8-.9 1.1-.4.3-.8.5-1.3.7-.5.2-1 .3-1.5.3-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1-.1-.1 1-1.5 1-1.5zM9 15H7v-4H3v4H1V5h2v4h4V5h2v10z', + 6: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm8.6-7.5c-.2-.2-.5-.4-.8-.5-.6-.2-1.3-.2-1.9 0-.3.1-.6.3-.8.5l-.6.9c-.2.5-.2.9-.2 1.4.4-.3.8-.6 1.2-.8.4-.2.8-.3 1.3-.3.4 0 .8 0 1.2.2.4.1.7.3 1 .6.3.3.5.6.7.9.2.4.3.8.3 1.3s-.1.9-.3 1.4c-.2.4-.5.7-.8 1-.4.3-.8.5-1.2.6-1 .3-2 .3-3 0-.5-.2-1-.5-1.4-.9-.4-.4-.8-.9-1-1.5-.2-.6-.3-1.3-.3-2.1s.1-1.6.4-2.3c.2-.6.6-1.2 1-1.6.4-.4.9-.7 1.4-.9.6-.3 1.1-.4 1.7-.4.7 0 1.4.1 2 .3.5.2 1 .5 1.4.8 0 .1-1.3 1.4-1.3 1.4zm-2.4 5.8c.2 0 .4 0 .6-.1.2 0 .4-.1.5-.2.1-.1.3-.3.4-.5.1-.2.1-.5.1-.7 0-.4-.1-.8-.4-1.1-.3-.2-.7-.3-1.1-.3-.3 0-.7.1-1 .2-.4.2-.7.4-1 .7 0 .3.1.7.3 1 .1.2.3.4.4.6.2.1.3.3.5.3.2.1.5.2.7.1z', + }; + if ( ! levelToPath.hasOwnProperty( level ) ) { + return null; + } + + return ( + <SVG width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> + <Path d={ levelToPath[ level ] } /> + </SVG> + ); +} diff --git a/gb-src/packages/block-library/src/heading/heading-toolbar.js b/gb-src/packages/block-library/src/heading/heading-toolbar.js new file mode 100644 index 0000000000000..f3a089a8fafef --- /dev/null +++ b/gb-src/packages/block-library/src/heading/heading-toolbar.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { range } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { Toolbar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import HeadingLevelIcon from './heading-level-icon'; + +class HeadingToolbar extends Component { + createLevelControl( targetLevel, selectedLevel, onChange ) { + return { + icon: <HeadingLevelIcon level={ targetLevel } />, + // translators: %s: heading level e.g: "1", "2", "3" + title: sprintf( __( 'Heading %d' ), targetLevel ), + isActive: targetLevel === selectedLevel, + onClick: () => onChange( targetLevel ), + }; + } + + render() { + const { isCollapsed = true, minLevel, maxLevel, selectedLevel, onChange } = this.props; + + return ( + <Toolbar + isCollapsed={ isCollapsed } + icon={ <HeadingLevelIcon level={ selectedLevel } /> } + controls={ range( minLevel, maxLevel ).map( + ( index ) => this.createLevelControl( index, selectedLevel, onChange ) + ) } /> + ); + } +} + +export default HeadingToolbar; diff --git a/gb-src/packages/block-library/src/heading/index.js b/gb-src/packages/block-library/src/heading/index.js new file mode 100644 index 0000000000000..ce2c1f29c5289 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Heading' ), + description: __( 'Introduce new sections and organize content to help visitors (and search engines) understand the structure of your content.' ), + icon: 'heading', + keywords: [ __( 'title' ), __( 'subtitle' ) ], + supports: { + className: false, + anchor: true, + }, + example: { + attributes: { + content: __( 'Code is Poetry' ), + level: 2, + }, + }, + transforms, + deprecated, + merge( attributes, attributesToMerge ) { + return { + content: ( attributes.content || '' ) + ( attributesToMerge.content || '' ), + }; + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/heading/index.native.js b/gb-src/packages/block-library/src/heading/index.native.js new file mode 100644 index 0000000000000..67a6d2d91ded0 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/index.native.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { create } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + __experimentalGetAccessibilityLabel( attributes ) { + const { content, level } = attributes; + + const plainTextContent = ( html ) => create( { html } ).text || ''; + + return isEmpty( content ) ? + sprintf( + /* translators: accessibility text. %s: heading level. */ + __( 'Level %s. Empty.' ), + level + ) : + sprintf( + /* translators: accessibility text. 1: heading level. 2: heading content. */ + __( 'Level %1$s. %2$s' ), + level, + plainTextContent( content ) + ); + }, +}; diff --git a/gb-src/packages/block-library/src/heading/save.js b/gb-src/packages/block-library/src/heading/save.js new file mode 100644 index 0000000000000..e8ace3129a6ed --- /dev/null +++ b/gb-src/packages/block-library/src/heading/save.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, + RichText, +} from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + align, + content, + customTextColor, + level, + textColor, + } = attributes; + const tagName = 'h' + level; + + const textClass = getColorClassName( 'color', textColor ); + + const className = classnames( { + [ textClass ]: textClass, + 'has-text-color': textColor || customTextColor, + [ `has-text-align-${ align }` ]: align, + } ); + + return ( + <RichText.Content + className={ className ? className : undefined } + tagName={ tagName } + style={ { + color: textClass ? undefined : customTextColor, + } } + value={ content } + /> + ); +} diff --git a/gb-src/packages/block-library/src/heading/shared.js b/gb-src/packages/block-library/src/heading/shared.js new file mode 100644 index 0000000000000..604eaa9adba8f --- /dev/null +++ b/gb-src/packages/block-library/src/heading/shared.js @@ -0,0 +1,10 @@ +/** + * Given a node name string for a heading node, returns its numeric level. + * + * @param {string} nodeName Heading node name. + * + * @return {number} Heading level. + */ +export function getLevelFromHeadingNodeName( nodeName ) { + return Number( nodeName.substr( 1 ) ); +} diff --git a/gb-src/packages/block-library/src/heading/style.native.scss b/gb-src/packages/block-library/src/heading/style.native.scss new file mode 100644 index 0000000000000..f4801b92a6bc5 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/style.native.scss @@ -0,0 +1,4 @@ + +.blockText { + min-height: $min-height-heading; +} diff --git a/gb-src/packages/block-library/src/heading/test/shared.js b/gb-src/packages/block-library/src/heading/test/shared.js new file mode 100644 index 0000000000000..e601a3d98b3d7 --- /dev/null +++ b/gb-src/packages/block-library/src/heading/test/shared.js @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import { getLevelFromHeadingNodeName } from '../shared'; + +describe( 'getLevelFromHeadingNodeName()', () => { + it( 'should return a numeric value from nodeName', () => { + const level = getLevelFromHeadingNodeName( 'H4' ); + + expect( level ).toBe( 4 ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/heading/transforms.js b/gb-src/packages/block-library/src/heading/transforms.js new file mode 100644 index 0000000000000..b043018a400ca --- /dev/null +++ b/gb-src/packages/block-library/src/heading/transforms.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getPhrasingContentSchema, + getBlockAttributes, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { getLevelFromHeadingNodeName } from './shared'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { content } ) => { + return createBlock( 'core/heading', { + content, + } ); + }, + }, + { + type: 'raw', + selector: 'h1,h2,h3,h4,h5,h6', + schema: { + h1: { children: getPhrasingContentSchema() }, + h2: { children: getPhrasingContentSchema() }, + h3: { children: getPhrasingContentSchema() }, + h4: { children: getPhrasingContentSchema() }, + h5: { children: getPhrasingContentSchema() }, + h6: { children: getPhrasingContentSchema() }, + }, + transform( node ) { + return createBlock( 'core/heading', { + ...getBlockAttributes( + 'core/heading', + node.outerHTML + ), + level: getLevelFromHeadingNodeName( node.nodeName ), + } ); + }, + }, + ...[ 2, 3, 4, 5, 6 ].map( ( level ) => ( { + type: 'prefix', + prefix: Array( level + 1 ).join( '#' ), + transform( content ) { + return createBlock( 'core/heading', { + level, + content, + } ); + }, + } ) ), + ], + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { content } ) => { + return createBlock( 'core/paragraph', { + content, + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/html/block.json b/gb-src/packages/block-library/src/html/block.json new file mode 100644 index 0000000000000..1a62e7c76a36d --- /dev/null +++ b/gb-src/packages/block-library/src/html/block.json @@ -0,0 +1,10 @@ +{ + "name": "core/html", + "category": "formatting", + "attributes": { + "content": { + "type": "string", + "source": "html" + } + } +} diff --git a/gb-src/packages/block-library/src/html/edit.js b/gb-src/packages/block-library/src/html/edit.js new file mode 100644 index 0000000000000..4f1ab45a073df --- /dev/null +++ b/gb-src/packages/block-library/src/html/edit.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { + BlockControls, + PlainText, + transformStyles, +} from '@wordpress/block-editor'; +import { Disabled, SandBox } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; + +class HTMLEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + isPreview: false, + styles: [], + }; + this.switchToHTML = this.switchToHTML.bind( this ); + this.switchToPreview = this.switchToPreview.bind( this ); + } + + componentDidMount() { + const { styles } = this.props; + + // Default styles used to unset some of the styles + // that might be inherited from the editor style. + const defaultStyles = ` + html,body,:root { + margin: 0 !important; + padding: 0 !important; + overflow: visible !important; + min-height: auto !important; + } + `; + + this.setState( { styles: [ + defaultStyles, + ...transformStyles( styles ), + ] } ); + } + + switchToPreview() { + this.setState( { isPreview: true } ); + } + + switchToHTML() { + this.setState( { isPreview: false } ); + } + + render() { + const { attributes, setAttributes } = this.props; + const { isPreview, styles } = this.state; + + return ( + <div className="wp-block-html"> + <BlockControls> + <div className="components-toolbar"> + <button + className={ `components-tab-button ${ ! isPreview ? 'is-active' : '' }` } + onClick={ this.switchToHTML } + > + <span>HTML</span> + </button> + <button + className={ `components-tab-button ${ isPreview ? 'is-active' : '' }` } + onClick={ this.switchToPreview } + > + <span>{ __( 'Preview' ) }</span> + </button> + </div> + </BlockControls> + <Disabled.Consumer> + { ( isDisabled ) => ( + ( isPreview || isDisabled ) ? ( + <SandBox html={ attributes.content } styles={ styles } /> + ) : ( + <PlainText + value={ attributes.content } + onChange={ ( content ) => setAttributes( { content } ) } + placeholder={ __( 'Write HTML…' ) } + aria-label={ __( 'HTML' ) } + /> + ) + ) } + </Disabled.Consumer> + </div> + ); + } +} +export default withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + return { + styles: getSettings().styles, + }; +} )( HTMLEdit ); diff --git a/gb-src/packages/block-library/src/html/editor.scss b/gb-src/packages/block-library/src/html/editor.scss new file mode 100644 index 0000000000000..2638318a4b739 --- /dev/null +++ b/gb-src/packages/block-library/src/html/editor.scss @@ -0,0 +1,22 @@ +.wp-block-html { + margin-bottom: $default-block-margin; + + .block-editor-plain-text { + font-family: $editor-html-font; + color: $dark-gray-800; + padding: 0.8em 1em; + border: 1px solid $light-gray-500; + border-radius: 4px; + max-height: 250px; + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + @include break-small { + font-size: $default-font-size; + } + + &:focus { + box-shadow: none; + } + } +} diff --git a/gb-src/packages/block-library/src/html/icon.js b/gb-src/packages/block-library/src/html/icon.js new file mode 100644 index 0000000000000..789175b0cf0c4 --- /dev/null +++ b/gb-src/packages/block-library/src/html/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M4.5,11h-2V9H1v6h1.5v-2.5h2V15H6V9H4.5V11z M7,10.5h1.5V15H10v-4.5h1.5V9H7V10.5z M14.5,10l-1-1H12v6h1.5v-3.9 l1,1l1-1V15H17V9h-1.5L14.5,10z M19.5,13.5V9H18v6h5v-1.5H19.5z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/html/index.js b/gb-src/packages/block-library/src/html/index.js new file mode 100644 index 0000000000000..5d1ea9e0860fe --- /dev/null +++ b/gb-src/packages/block-library/src/html/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Custom HTML' ), + description: __( 'Add custom HTML code and preview it as you edit.' ), + icon, + keywords: [ __( 'embed' ) ], + example: { + attributes: { + content: '<marquee>' + __( 'Welcome to the wonderful world of blocks…' ) + '</marquee>', + }, + }, + supports: { + customClassName: false, + className: false, + html: false, + }, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/html/save.js b/gb-src/packages/block-library/src/html/save.js new file mode 100644 index 0000000000000..08d72be511bb2 --- /dev/null +++ b/gb-src/packages/block-library/src/html/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save( { attributes } ) { + return <RawHTML>{ attributes.content }</RawHTML>; +} diff --git a/gb-src/packages/block-library/src/html/transforms.js b/gb-src/packages/block-library/src/html/transforms.js new file mode 100644 index 0000000000000..a54c17ef8f6de --- /dev/null +++ b/gb-src/packages/block-library/src/html/transforms.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { getPhrasingContentSchema } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'raw', + isMatch: ( node ) => node.nodeName === 'FIGURE' && !! node.querySelector( 'iframe' ), + schema: { + figure: { + require: [ 'iframe' ], + children: { + iframe: { + attributes: [ 'src', 'allowfullscreen', 'height', 'width' ], + }, + figcaption: { + children: getPhrasingContentSchema(), + }, + }, + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/image/block.json b/gb-src/packages/block-library/src/image/block.json new file mode 100644 index 0000000000000..68d2559ae4f48 --- /dev/null +++ b/gb-src/packages/block-library/src/image/block.json @@ -0,0 +1,67 @@ +{ + "name": "core/image", + "category": "common", + "attributes": { + "align": { + "type": "string" + }, + "url": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "alt": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "alt", + "default": "" + }, + "caption": { + "type": "string", + "source": "html", + "selector": "figcaption" + }, + "href": { + "type": "string", + "source": "attribute", + "selector": "figure > a", + "attribute": "href" + }, + "rel": { + "type": "string", + "source": "attribute", + "selector": "figure > a", + "attribute": "rel" + }, + "linkClass": { + "type": "string", + "source": "attribute", + "selector": "figure > a", + "attribute": "class" + }, + "id": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "sizeSlug": { + "type": "string" + }, + "linkDestination": { + "type": "string", + "default": "none" + }, + "linkTarget": { + "type": "string", + "source": "attribute", + "selector": "figure > a", + "attribute": "target" + } + } +} diff --git a/gb-src/packages/block-library/src/image/deprecated.js b/gb-src/packages/block-library/src/image/deprecated.js new file mode 100644 index 0000000000000..952cb77b52cc1 --- /dev/null +++ b/gb-src/packages/block-library/src/image/deprecated.js @@ -0,0 +1,149 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +const blockAttributes = { + align: { + type: 'string', + }, + url: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + alt: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + caption: { + type: 'string', + source: 'html', + selector: 'figcaption', + }, + href: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'href', + }, + rel: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'rel', + }, + linkClass: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'class', + }, + id: { + type: 'number', + }, + width: { + type: 'number', + }, + height: { + type: 'number', + }, + linkDestination: { + type: 'string', + default: 'none', + }, + linkTarget: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'target', + }, +}; + +const deprecated = [ + { + attributes: blockAttributes, + save( { attributes } ) { + const { url, alt, caption, align, href, width, height, id } = attributes; + + const classes = classnames( { + [ `align${ align }` ]: align, + 'is-resized': width || height, + } ); + + const image = ( + <img + src={ url } + alt={ alt } + className={ id ? `wp-image-${ id }` : null } + width={ width } + height={ height } + /> + ); + + return ( + <figure className={ classes }> + { href ? <a href={ href }>{ image }</a> : image } + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); + }, + }, + { + attributes: blockAttributes, + save( { attributes } ) { + const { url, alt, caption, align, href, width, height, id } = attributes; + + const image = ( + <img + src={ url } + alt={ alt } + className={ id ? `wp-image-${ id }` : null } + width={ width } + height={ height } + /> + ); + + return ( + <figure className={ align ? `align${ align }` : null } > + { href ? <a href={ href }>{ image }</a> : image } + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); + }, + }, + { + attributes: blockAttributes, + save( { attributes } ) { + const { url, alt, caption, align, href, width, height } = attributes; + const extraImageProps = width || height ? { width, height } : {}; + const image = <img src={ url } alt={ alt } { ...extraImageProps } />; + + let figureStyle = {}; + + if ( width ) { + figureStyle = { width }; + } else if ( align === 'left' || align === 'right' ) { + figureStyle = { maxWidth: '50%' }; + } + + return ( + <figure className={ align ? `align${ align }` : null } style={ figureStyle }> + { href ? <a href={ href }>{ image }</a> : image } + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </figure> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/image/edit.js b/gb-src/packages/block-library/src/image/edit.js new file mode 100644 index 0000000000000..bbca559088299 --- /dev/null +++ b/gb-src/packages/block-library/src/image/edit.js @@ -0,0 +1,960 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { + find, + get, + isEmpty, + map, + last, + omit, + pick, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +import { + Button, + ButtonGroup, + ExternalLink, + IconButton, + MenuItem, + NavigableMenu, + PanelBody, + Path, + Rect, + ResizableBox, + SelectControl, + Spinner, + SVG, + TextareaControl, + TextControl, + ToggleControl, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { + LEFT, + RIGHT, + UP, + DOWN, + BACKSPACE, + ENTER, +} from '@wordpress/keycodes'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { + BlockAlignmentToolbar, + BlockControls, + BlockIcon, + InspectorControls, + MediaPlaceholder, + URLPopover, + RichText, +} from '@wordpress/block-editor'; +import { + Component, + useCallback, + useState, + useRef, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { getPath } from '@wordpress/url'; +import { withViewportMatch } from '@wordpress/viewport'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import { createUpgradedEmbedBlock } from '../embed/util'; +import icon from './icon'; +import ImageSize from './image-size'; + +/** + * Module constants + */ +const MIN_SIZE = 20; +const LINK_DESTINATION_NONE = 'none'; +const LINK_DESTINATION_MEDIA = 'media'; +const LINK_DESTINATION_ATTACHMENT = 'attachment'; +const LINK_DESTINATION_CUSTOM = 'custom'; +const NEW_TAB_REL = 'noreferrer noopener'; +const ALLOWED_MEDIA_TYPES = [ 'image' ]; +const DEFAULT_SIZE_SLUG = 'large'; + +export const pickRelevantMediaFiles = ( image ) => { + const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); + imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url; + return imageProps; +}; + +/** + * Is the URL a temporary blob URL? A blob URL is one that is used temporarily + * while the image is being uploaded and will not have an id yet allocated. + * + * @param {number=} id The id of the image. + * @param {string=} url The url of the image. + * + * @return {boolean} Is the URL a Blob URL + */ +const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url ); + +/** + * Is the url for the image hosted externally. An externally hosted image has no id + * and is not a blob url. + * + * @param {number=} id The id of the image. + * @param {string=} url The url of the image. + * + * @return {boolean} Is the url an externally hosted url? + */ +const isExternalImage = ( id, url ) => url && ! id && ! isBlobURL( url ); + +const stopPropagation = ( event ) => { + event.stopPropagation(); +}; + +const stopPropagationRelevantKeys = ( event ) => { + if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { + // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. + event.stopPropagation(); + } +}; + +const ImageURLInputUI = ( { + advancedOptions, + linkDestination, + mediaLinks, + onChangeUrl, + url, +} ) => { + const [ isOpen, setIsOpen ] = useState( false ); + const openLinkUI = useCallback( () => { + setIsOpen( true ); + } ); + + const [ isEditingLink, setIsEditingLink ] = useState( false ); + const [ urlInput, setUrlInput ] = useState( null ); + + const startEditLink = useCallback( () => { + if ( linkDestination === LINK_DESTINATION_MEDIA || + linkDestination === LINK_DESTINATION_ATTACHMENT + ) { + setUrlInput( '' ); + } + setIsEditingLink( true ); + } ); + const stopEditLink = useCallback( () => { + setIsEditingLink( false ); + } ); + + const closeLinkUI = useCallback( () => { + setUrlInput( null ); + stopEditLink(); + setIsOpen( false ); + } ); + + const autocompleteRef = useRef( null ); + + const onClickOutside = useCallback( () => { + return ( event ) => { + // The autocomplete suggestions list renders in a separate popover (in a portal), + // so onClickOutside fails to detect that a click on a suggestion occurred in the + // LinkContainer. Detect clicks on autocomplete suggestions using a ref here, and + // return to avoid the popover being closed. + const autocompleteElement = autocompleteRef.current; + if ( autocompleteElement && autocompleteElement.contains( event.target ) ) { + return; + } + setIsOpen( false ); + setUrlInput( null ); + stopEditLink(); + }; + } ); + + const onSubmitLinkChange = useCallback( () => { + return ( event ) => { + if ( urlInput ) { + onChangeUrl( urlInput ); + } + stopEditLink(); + setUrlInput( null ); + event.preventDefault(); + }; + } ); + + const onLinkRemove = useCallback( () => { + closeLinkUI(); + onChangeUrl( '' ); + } ); + const linkEditorValue = urlInput !== null ? urlInput : url; + + const urlLabel = ( + find( mediaLinks, [ 'linkDestination', linkDestination ] ) || {} + ).title; + return ( + <> + <IconButton + icon="admin-links" + className="components-toolbar__control" + label={ url ? __( 'Edit link' ) : __( 'Insert link' ) } + aria-expanded={ isOpen } + onClick={ openLinkUI } + /> + { isOpen && ( + <URLPopover + onClickOutside={ onClickOutside() } + onClose={ closeLinkUI } + renderSettings={ () => advancedOptions } + additionalControls={ ! linkEditorValue && ( + <NavigableMenu> + { + map( mediaLinks, ( link ) => ( + <MenuItem + key={ link.linkDestination } + icon={ link.icon } + onClick={ () => { + setUrlInput( null ); + onChangeUrl( link.url ); + stopEditLink(); + } } + > + { link.title } + </MenuItem> + ) ) + } + </NavigableMenu> + ) } + > + { ( ! url || isEditingLink ) && ( + <URLPopover.LinkEditor + className="editor-format-toolbar__link-container-content block-editor-format-toolbar__link-container-content" + value={ linkEditorValue } + onChangeInputValue={ setUrlInput } + onKeyDown={ stopPropagationRelevantKeys } + onKeyPress={ stopPropagation } + onSubmit={ onSubmitLinkChange() } + autocompleteRef={ autocompleteRef } + /> + ) } + { ( url && ! isEditingLink ) && ( + <> + <URLPopover.LinkViewer + className="editor-format-toolbar__link-container-content block-editor-format-toolbar__link-container-content" + onKeyPress={ stopPropagation } + url={ url } + onEditLinkClick={ startEditLink } + urlLabel={ urlLabel } + /> + <IconButton + icon="no" + label={ __( 'Remove link' ) } + onClick={ onLinkRemove } + /> + </> + ) } + </URLPopover> + ) } + </> + ); +}; + +export class ImageEdit extends Component { + constructor( { attributes } ) { + super( ...arguments ); + this.updateAlt = this.updateAlt.bind( this ); + this.updateAlignment = this.updateAlignment.bind( this ); + this.onFocusCaption = this.onFocusCaption.bind( this ); + this.onImageClick = this.onImageClick.bind( this ); + this.onSelectImage = this.onSelectImage.bind( this ); + this.onSelectURL = this.onSelectURL.bind( this ); + this.updateImage = this.updateImage.bind( this ); + this.updateWidth = this.updateWidth.bind( this ); + this.updateHeight = this.updateHeight.bind( this ); + this.updateDimensions = this.updateDimensions.bind( this ); + this.onSetHref = this.onSetHref.bind( this ); + this.onSetLinkClass = this.onSetLinkClass.bind( this ); + this.onSetLinkRel = this.onSetLinkRel.bind( this ); + this.onSetNewTab = this.onSetNewTab.bind( this ); + this.getFilename = this.getFilename.bind( this ); + this.toggleIsEditing = this.toggleIsEditing.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + this.onImageError = this.onImageError.bind( this ); + this.getLinkDestinations = this.getLinkDestinations.bind( this ); + + this.state = { + captionFocused: false, + isEditing: ! attributes.url, + }; + } + + componentDidMount() { + const { + attributes, + mediaUpload, + noticeOperations, + } = this.props; + const { id, url = '' } = attributes; + + if ( isTemporaryImage( id, url ) ) { + const file = getBlobByURL( url ); + + if ( file ) { + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ image ] ) => { + this.onSelectImage( image ); + }, + allowedTypes: ALLOWED_MEDIA_TYPES, + onError: ( message ) => { + noticeOperations.createErrorNotice( message ); + this.setState( { isEditing: true } ); + }, + } ); + } + } + } + + componentDidUpdate( prevProps ) { + const { id: prevID, url: prevURL = '' } = prevProps.attributes; + const { id, url = '' } = this.props.attributes; + + if ( isTemporaryImage( prevID, prevURL ) && ! isTemporaryImage( id, url ) ) { + revokeBlobURL( url ); + } + + if ( ! this.props.isSelected && prevProps.isSelected && this.state.captionFocused ) { + this.setState( { + captionFocused: false, + } ); + } + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + this.setState( { + isEditing: true, + } ); + } + + onSelectImage( media ) { + if ( ! media || ! media.url ) { + this.props.setAttributes( { + url: undefined, + alt: undefined, + id: undefined, + caption: undefined, + } ); + return; + } + + this.setState( { + isEditing: false, + } ); + + const { id, url, alt, caption } = this.props.attributes; + + let mediaAttributes = pickRelevantMediaFiles( media ); + + // If the current image is temporary but an alt or caption text was meanwhile written by the user, + // make sure the text is not overwritten. + if ( isTemporaryImage( id, url ) ) { + if ( alt ) { + mediaAttributes = omit( mediaAttributes, [ 'alt' ] ); + } + if ( caption ) { + mediaAttributes = omit( mediaAttributes, [ 'caption' ] ); + } + } + + let additionalAttributes; + // Reset the dimension attributes if changing to a different image. + if ( ! media.id || media.id !== id ) { + additionalAttributes = { + width: undefined, + height: undefined, + sizeSlug: DEFAULT_SIZE_SLUG, + }; + } else { + // Keep the same url when selecting the same file, so "Image Size" option is not changed. + additionalAttributes = { url }; + } + + this.props.setAttributes( { + ...mediaAttributes, + ...additionalAttributes, + } ); + } + + onSelectURL( newURL ) { + const { url } = this.props.attributes; + + if ( newURL !== url ) { + this.props.setAttributes( { + url: newURL, + id: undefined, + sizeSlug: DEFAULT_SIZE_SLUG, + } ); + } + + this.setState( { + isEditing: false, + } ); + } + + onImageError( url ) { + // Check if there's an embed block that handles this URL. + const embedBlock = createUpgradedEmbedBlock( + { attributes: { url } } + ); + if ( undefined !== embedBlock ) { + this.props.onReplace( embedBlock ); + } + } + + onSetHref( value ) { + const linkDestinations = this.getLinkDestinations(); + const { attributes } = this.props; + const { linkDestination } = attributes; + let linkDestinationInput; + if ( ! value ) { + linkDestinationInput = LINK_DESTINATION_NONE; + } else { + linkDestinationInput = ( + find( linkDestinations, ( destination ) => { + return destination.url === value; + } ) || + { linkDestination: LINK_DESTINATION_CUSTOM } + ).linkDestination; + } + if ( linkDestination !== linkDestinationInput ) { + this.props.setAttributes( { + linkDestination: linkDestinationInput, + href: value, + } ); + return; + } + this.props.setAttributes( { href: value } ); + } + + onSetLinkClass( value ) { + this.props.setAttributes( { linkClass: value } ); + } + + onSetLinkRel( value ) { + this.props.setAttributes( { rel: value } ); + } + + onSetNewTab( value ) { + const { rel } = this.props.attributes; + const linkTarget = value ? '_blank' : undefined; + + let updatedRel = rel; + if ( linkTarget && ! rel ) { + updatedRel = NEW_TAB_REL; + } else if ( ! linkTarget && rel === NEW_TAB_REL ) { + updatedRel = undefined; + } + + this.props.setAttributes( { + linkTarget, + rel: updatedRel, + } ); + } + + onFocusCaption() { + if ( ! this.state.captionFocused ) { + this.setState( { + captionFocused: true, + } ); + } + } + + onImageClick() { + if ( this.state.captionFocused ) { + this.setState( { + captionFocused: false, + } ); + } + } + + updateAlt( newAlt ) { + this.props.setAttributes( { alt: newAlt } ); + } + + updateAlignment( nextAlign ) { + const extraUpdatedAttributes = [ 'wide', 'full' ].indexOf( nextAlign ) !== -1 ? + { width: undefined, height: undefined } : + {}; + this.props.setAttributes( { ...extraUpdatedAttributes, align: nextAlign } ); + } + + updateImage( sizeSlug ) { + const { image } = this.props; + + const url = get( image, [ 'media_details', 'sizes', sizeSlug, 'source_url' ] ); + if ( ! url ) { + return null; + } + + this.props.setAttributes( { + url, + width: undefined, + height: undefined, + sizeSlug, + } ); + } + + updateWidth( width ) { + this.props.setAttributes( { width: parseInt( width, 10 ) } ); + } + + updateHeight( height ) { + this.props.setAttributes( { height: parseInt( height, 10 ) } ); + } + + updateDimensions( width = undefined, height = undefined ) { + return () => { + this.props.setAttributes( { width, height } ); + }; + } + + getFilename( url ) { + const path = getPath( url ); + if ( path ) { + return last( path.split( '/' ) ); + } + } + + getLinkDestinations() { + return [ + { + linkDestination: LINK_DESTINATION_MEDIA, + title: __( 'Media File' ), + url: ( this.props.image && this.props.image.source_url ) || + this.props.attributes.url, + icon, + }, + { + linkDestination: LINK_DESTINATION_ATTACHMENT, + title: __( 'Attachment Page' ), + url: this.props.image && this.props.image.link, + icon: <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0 0h24v24H0V0z" fill="none" /><Path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z" /></SVG>, + }, + ]; + } + + toggleIsEditing() { + this.setState( { + isEditing: ! this.state.isEditing, + } ); + if ( this.state.isEditing ) { + speak( __( 'You are now viewing the image in the image block.' ) ); + } else { + speak( __( 'You are now editing the image in the image block.' ) ); + } + } + + getImageSizeOptions() { + const { imageSizes } = this.props; + return map( imageSizes, ( { name, slug } ) => ( { value: slug, label: name } ) ); + } + + render() { + const { isEditing } = this.state; + const { + attributes, + setAttributes, + isLargeViewport, + isSelected, + className, + maxWidth, + noticeUI, + isRTL, + onResizeStart, + onResizeStop, + } = this.props; + const { + url, + alt, + caption, + align, + id, + href, + rel, + linkClass, + linkDestination, + width, + height, + linkTarget, + sizeSlug, + } = attributes; + + const isExternal = isExternalImage( id, url ); + const editImageIcon = ( <SVG width={ 20 } height={ 20 } viewBox="0 0 20 20"><Rect x={ 11 } y={ 3 } width={ 7 } height={ 5 } rx={ 1 } /><Rect x={ 2 } y={ 12 } width={ 7 } height={ 5 } rx={ 1 } /><Path d="M13,12h1a3,3,0,0,1-3,3v2a5,5,0,0,0,5-5h1L15,9Z" /><Path d="M4,8H3l2,3L7,8H6A3,3,0,0,1,9,5V3A5,5,0,0,0,4,8Z" /></SVG> ); + const controls = ( + <BlockControls> + <BlockAlignmentToolbar + value={ align } + onChange={ this.updateAlignment } + /> + { url && ( + <> + <Toolbar> + <IconButton + className={ classnames( 'components-icon-button components-toolbar__control', { 'is-active': this.state.isEditing } ) } + label={ __( 'Edit image' ) } + aria-pressed={ this.state.isEditing } + onClick={ this.toggleIsEditing } + icon={ editImageIcon } + /> + </Toolbar> + <Toolbar> + <ImageURLInputUI + url={ href || '' } + onChangeUrl={ this.onSetHref } + mediaLinks={ this.getLinkDestinations() } + linkDestination={ linkDestination } + advancedOptions={ + <> + <ToggleControl + label={ __( 'Open in New Tab' ) } + onChange={ this.onSetNewTab } + checked={ linkTarget === '_blank' } /> + <TextControl + label={ __( 'Link CSS Class' ) } + value={ linkClass || '' } + onKeyPress={ stopPropagation } + onKeyDown={ stopPropagationRelevantKeys } + onChange={ this.onSetLinkClass } + /> + <TextControl + label={ __( 'Link Rel' ) } + value={ rel || '' } + onChange={ this.onSetLinkRel } + onKeyPress={ stopPropagation } + onKeyDown={ stopPropagationRelevantKeys } + /> + </> + } + /> + </Toolbar> + </> + ) } + </BlockControls> + ); + const src = isExternal ? url : undefined; + const labels = { + title: ! url ? __( 'Image' ) : __( 'Edit image' ), + instructions: __( 'Upload an image file, pick one from your media library, or add one with a URL.' ), + }; + const mediaPreview = ( !! url && <img + alt={ __( 'Edit image' ) } + title={ __( 'Edit image' ) } + className={ 'edit-image-preview' } + src={ url } + /> ); + const mediaPlaceholder = ( + <MediaPlaceholder + icon={ <BlockIcon icon={ icon } /> } + className={ className } + labels={ labels } + onSelect={ this.onSelectImage } + onSelectURL={ this.onSelectURL } + onDoubleClick={ this.toggleIsEditing } + onCancel={ !! url && this.toggleIsEditing } + notices={ noticeUI } + onError={ this.onUploadError } + accept="image/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ { id, src } } + mediaPreview={ mediaPreview } + dropZoneUIOnly={ ! isEditing && url } + /> + ); + if ( isEditing || ! url ) { + return ( + <> + { controls } + { mediaPlaceholder } + </> + ); + } + + const classes = classnames( className, { + 'is-transient': isBlobURL( url ), + 'is-resized': !! width || !! height, + 'is-focused': isSelected, + [ `size-${ sizeSlug }` ]: sizeSlug, + } ); + + const isResizable = [ 'wide', 'full' ].indexOf( align ) === -1 && isLargeViewport; + + const imageSizeOptions = this.getImageSizeOptions(); + + const getInspectorControls = ( imageWidth, imageHeight ) => ( + <InspectorControls> + <PanelBody title={ __( 'Image Settings' ) }> + <TextareaControl + label={ __( 'Alt Text (Alternative Text)' ) } + value={ alt } + onChange={ this.updateAlt } + help={ + <> + <ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree"> + { __( 'Describe the purpose of the image' ) } + </ExternalLink> + { __( 'Leave empty if the image is purely decorative.' ) } + </> + } + /> + { ! isEmpty( imageSizeOptions ) && ( + <SelectControl + label={ __( 'Image Size' ) } + value={ sizeSlug } + options={ imageSizeOptions } + onChange={ this.updateImage } + /> + ) } + { isResizable && ( + <div className="block-library-image__dimensions"> + <p className="block-library-image__dimensions__row"> + { __( 'Image Dimensions' ) } + </p> + <div className="block-library-image__dimensions__row"> + <TextControl + type="number" + className="block-library-image__dimensions__width" + label={ __( 'Width' ) } + value={ width || imageWidth || '' } + min={ 1 } + onChange={ this.updateWidth } + /> + <TextControl + type="number" + className="block-library-image__dimensions__height" + label={ __( 'Height' ) } + value={ height || imageHeight || '' } + min={ 1 } + onChange={ this.updateHeight } + /> + </div> + <div className="block-library-image__dimensions__row"> + <ButtonGroup aria-label={ __( 'Image Size' ) }> + { [ 25, 50, 75, 100 ].map( ( scale ) => { + const scaledWidth = Math.round( imageWidth * ( scale / 100 ) ); + const scaledHeight = Math.round( imageHeight * ( scale / 100 ) ); + + const isCurrent = width === scaledWidth && height === scaledHeight; + + return ( + <Button + key={ scale } + isSmall + isPrimary={ isCurrent } + aria-pressed={ isCurrent } + onClick={ this.updateDimensions( scaledWidth, scaledHeight ) } + > + { scale }% + </Button> + ); + } ) } + </ButtonGroup> + <Button + isSmall + onClick={ this.updateDimensions() } + > + { __( 'Reset' ) } + </Button> + </div> + </div> + ) } + </PanelBody> + </InspectorControls> + ); + + // Disable reason: Each block can be selected by clicking on it + /* eslint-disable jsx-a11y/click-events-have-key-events */ + return ( + <> + { controls } + <figure className={ classes }> + <ImageSize src={ url } dirtynessTrigger={ align }> + { ( sizes ) => { + const { + imageWidthWithinContainer, + imageHeightWithinContainer, + imageWidth, + imageHeight, + } = sizes; + + const filename = this.getFilename( url ); + let defaultedAlt; + if ( alt ) { + defaultedAlt = alt; + } else if ( filename ) { + defaultedAlt = sprintf( __( 'This image has an empty alt attribute; its file name is %s' ), filename ); + } else { + defaultedAlt = __( 'This image has an empty alt attribute' ); + } + + const img = ( + // Disable reason: Image itself is not meant to be interactive, but + // should direct focus to block. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <> + <img + src={ url } + alt={ defaultedAlt } + onDoubleClick={ this.toggleIsEditing } + onClick={ this.onImageClick } + onError={ () => this.onImageError( url ) } + /> + { isBlobURL( url ) && <Spinner /> } + </> + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); + + if ( ! isResizable || ! imageWidthWithinContainer ) { + return ( + <> + { getInspectorControls( imageWidth, imageHeight ) } + <div style={ { width, height } }> + { img } + </div> + </> + ); + } + + const currentWidth = width || imageWidthWithinContainer; + const currentHeight = height || imageHeightWithinContainer; + + const ratio = imageWidth / imageHeight; + const minWidth = imageWidth < imageHeight ? MIN_SIZE : MIN_SIZE * ratio; + const minHeight = imageHeight < imageWidth ? MIN_SIZE : MIN_SIZE / ratio; + + // With the current implementation of ResizableBox, an image needs an explicit pixel value for the max-width. + // In absence of being able to set the content-width, this max-width is currently dictated by the vanilla editor style. + // The following variable adds a buffer to this vanilla style, so 3rd party themes have some wiggleroom. + // This does, in most cases, allow you to scale the image beyond the width of the main column, though not infinitely. + // @todo It would be good to revisit this once a content-width variable becomes available. + const maxWidthBuffer = maxWidth * 2.5; + + let showRightHandle = false; + let showLeftHandle = false; + + /* eslint-disable no-lonely-if */ + // See https://github.com/WordPress/gutenberg/issues/7584. + if ( align === 'center' ) { + // When the image is centered, show both handles. + showRightHandle = true; + showLeftHandle = true; + } else if ( isRTL ) { + // In RTL mode the image is on the right by default. + // Show the right handle and hide the left handle only when it is aligned left. + // Otherwise always show the left handle. + if ( align === 'left' ) { + showRightHandle = true; + } else { + showLeftHandle = true; + } + } else { + // Show the left handle and hide the right handle only when the image is aligned right. + // Otherwise always show the right handle. + if ( align === 'right' ) { + showLeftHandle = true; + } else { + showRightHandle = true; + } + } + /* eslint-enable no-lonely-if */ + + return ( + <> + { getInspectorControls( imageWidth, imageHeight ) } + <ResizableBox + size={ { + width, + height, + } } + minWidth={ minWidth } + maxWidth={ maxWidthBuffer } + minHeight={ minHeight } + maxHeight={ maxWidthBuffer / ratio } + lockAspectRatio + enable={ { + top: false, + right: showRightHandle, + bottom: true, + left: showLeftHandle, + } } + onResizeStart={ onResizeStart } + onResizeStop={ ( event, direction, elt, delta ) => { + onResizeStop(); + setAttributes( { + width: parseInt( currentWidth + delta.width, 10 ), + height: parseInt( currentHeight + delta.height, 10 ), + } ); + } } + > + { img } + </ResizableBox> + </> + ); + } } + </ImageSize> + { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + unstableOnFocus={ this.onFocusCaption } + onChange={ ( value ) => setAttributes( { caption: value } ) } + isSelected={ this.state.captionFocused } + inlineToolbar + /> + ) } + </figure> + { mediaPlaceholder } + </> + ); + /* eslint-enable jsx-a11y/click-events-have-key-events */ + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { toggleSelection } = dispatch( 'core/block-editor' ); + + return { + onResizeStart: () => toggleSelection( false ), + onResizeStop: () => toggleSelection( true ), + }; + } ), + withSelect( ( select, props ) => { + const { getMedia } = select( 'core' ); + const { getSettings } = select( 'core/block-editor' ); + const { attributes: { id }, isSelected } = props; + const { + __experimentalMediaUpload, + imageSizes, + isRTL, + maxWidth, + } = getSettings(); + + return { + image: id && isSelected ? getMedia( id ) : null, + maxWidth, + isRTL, + imageSizes, + mediaUpload: __experimentalMediaUpload, + }; + } ), + withViewportMatch( { isLargeViewport: 'medium' } ), + withNotices, +] )( ImageEdit ); diff --git a/gb-src/packages/block-library/src/image/edit.native.js b/gb-src/packages/block-library/src/image/edit.native.js new file mode 100644 index 0000000000000..3eb19a6bfa991 --- /dev/null +++ b/gb-src/packages/block-library/src/image/edit.native.js @@ -0,0 +1,371 @@ +/** + * External dependencies + */ +import React from 'react'; +import { View, ImageBackground, Text, TouchableWithoutFeedback, Dimensions } from 'react-native'; +import { + requestMediaImport, + mediaUploadSync, + requestImageFailedRetryDialog, + requestImageUploadCancelDialog, +} from 'react-native-gutenberg-bridge'; +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + BottomSheet, + Icon, + Toolbar, + ToolbarButton, +} from '@wordpress/components'; +import { + Caption, + MediaPlaceholder, + MediaUpload, + MEDIA_TYPE_IMAGE, + BlockControls, + InspectorControls, +} from '@wordpress/block-editor'; +import { __, sprintf } from '@wordpress/i18n'; +import { isURL } from '@wordpress/url'; +import { doAction, hasAction } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; +import MediaUploadProgress from './media-upload-progress'; +import SvgIcon from './icon'; +import SvgIconRetry from './icon-retry'; + +const LINK_DESTINATION_CUSTOM = 'custom'; +const LINK_DESTINATION_NONE = 'none'; + +// Default Image ratio 4:3 +const IMAGE_ASPECT_RATIO = 4 / 3; + +class ImageEdit extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + showSettings: false, + isCaptionSelected: false, + }; + + this.finishMediaUploadWithSuccess = this.finishMediaUploadWithSuccess.bind( this ); + this.finishMediaUploadWithFailure = this.finishMediaUploadWithFailure.bind( this ); + this.mediaUploadStateReset = this.mediaUploadStateReset.bind( this ); + this.onSelectMediaUploadOption = this.onSelectMediaUploadOption.bind( this ); + this.updateMediaProgress = this.updateMediaProgress.bind( this ); + this.updateAlt = this.updateAlt.bind( this ); + this.updateImageURL = this.updateImageURL.bind( this ); + this.onSetLinkDestination = this.onSetLinkDestination.bind( this ); + this.onImagePressed = this.onImagePressed.bind( this ); + this.onClearSettings = this.onClearSettings.bind( this ); + this.onFocusCaption = this.onFocusCaption.bind( this ); + } + + componentDidMount() { + const { attributes, setAttributes } = this.props; + + // This will warn when we have `id` defined, while `url` is undefined. + // This may help track this issue: https://github.com/wordpress-mobile/WordPress-Android/issues/9768 + // where a cancelled image upload was resulting in a subsequent crash. + if ( attributes.id && ! attributes.url ) { + // eslint-disable-next-line no-console + console.warn( 'Attributes has id with no url.' ); + } + + if ( attributes.id && attributes.url && ! isURL( attributes.url ) ) { + if ( attributes.url.indexOf( 'file:' ) === 0 ) { + requestMediaImport( attributes.url, ( mediaId, mediaUri ) => { + if ( mediaUri ) { + setAttributes( { url: mediaUri, id: mediaId } ); + } + } ); + } + mediaUploadSync(); + } + } + + componentWillUnmount() { + // this action will only exist if the user pressed the trash button on the block holder + if ( hasAction( 'blocks.onRemoveBlockCheckUpload' ) && this.state.isUploadInProgress ) { + doAction( 'blocks.onRemoveBlockCheckUpload', this.props.attributes.id ); + } + } + + static getDerivedStateFromProps( props, state ) { + // Avoid a UI flicker in the toolbar by insuring that isCaptionSelected + // is updated immediately any time the isSelected prop becomes false + return { + isCaptionSelected: props.isSelected && state.isCaptionSelected, + }; + } + + onImagePressed() { + const { attributes } = this.props; + + if ( this.state.isUploadInProgress ) { + requestImageUploadCancelDialog( attributes.id ); + } else if ( attributes.id && ! isURL( attributes.url ) ) { + requestImageFailedRetryDialog( attributes.id ); + } + + this.setState( { + isCaptionSelected: false, + } ); + } + + updateMediaProgress( payload ) { + const { setAttributes } = this.props; + if ( payload.mediaUrl ) { + setAttributes( { url: payload.mediaUrl } ); + } + + if ( ! this.state.isUploadInProgress ) { + this.setState( { isUploadInProgress: true } ); + } + } + + finishMediaUploadWithSuccess( payload ) { + const { setAttributes } = this.props; + + setAttributes( { url: payload.mediaUrl, id: payload.mediaServerId } ); + this.setState( { isUploadInProgress: false } ); + } + + finishMediaUploadWithFailure( payload ) { + const { setAttributes } = this.props; + + setAttributes( { id: payload.mediaId } ); + this.setState( { isUploadInProgress: false } ); + } + + mediaUploadStateReset() { + const { setAttributes } = this.props; + + setAttributes( { id: null, url: null } ); + this.setState( { isUploadInProgress: false } ); + } + + updateAlt( newAlt ) { + this.props.setAttributes( { alt: newAlt } ); + } + + updateImageURL( url ) { + this.props.setAttributes( { url, width: undefined, height: undefined } ); + } + + onSetLinkDestination( href ) { + this.props.setAttributes( { + linkDestination: LINK_DESTINATION_CUSTOM, + href, + } ); + } + + onClearSettings() { + this.props.setAttributes( { + alt: '', + linkDestination: LINK_DESTINATION_NONE, + href: undefined, + } ); + } + + onSelectMediaUploadOption( mediaId, mediaUrl ) { + const { setAttributes } = this.props; + setAttributes( { url: mediaUrl, id: mediaId } ); + } + + onFocusCaption() { + if ( this.props.onFocus ) { + this.props.onFocus(); + } + if ( ! this.state.isCaptionSelected ) { + this.setState( { + isCaptionSelected: true, + } ); + } + } + + getIcon( isRetryIcon ) { + if ( isRetryIcon ) { + return <Icon icon={ SvgIconRetry } { ...styles.iconRetry } />; + } + + return <Icon icon={ SvgIcon } { ...styles.icon } />; + } + + render() { + const { attributes, isSelected } = this.props; + const { url, height, width, alt, href, id } = attributes; + + const onImageSettingsButtonPressed = () => { + this.setState( { showSettings: true } ); + }; + + const onImageSettingsClose = () => { + this.setState( { showSettings: false } ); + }; + + const getToolbarEditButton = ( open ) => ( + <BlockControls> + <Toolbar> + <ToolbarButton + title={ __( 'Edit image' ) } + icon="edit" + onClick={ open } + /> + </Toolbar> + </BlockControls> + ); + + const getInspectorControls = () => ( + <BottomSheet + isVisible={ this.state.showSettings } + onClose={ onImageSettingsClose } + hideHeader + > + <BottomSheet.Cell + icon={ 'admin-links' } + label={ __( 'Link To' ) } + value={ href || '' } + valuePlaceholder={ __( 'Add URL' ) } + onChangeValue={ this.onSetLinkDestination } + autoCapitalize="none" + autoCorrect={ false } + keyboardType="url" + /> + <BottomSheet.Cell + icon={ 'editor-textcolor' } + label={ __( 'Alt Text' ) } + value={ alt || '' } + valuePlaceholder={ __( 'None' ) } + separatorType={ 'fullWidth' } + onChangeValue={ this.updateAlt } + /> + <BottomSheet.Cell + label={ __( 'Clear All Settings' ) } + labelStyle={ styles.clearSettingsButton } + separatorType={ 'none' } + onPress={ this.onClearSettings } + /> + </BottomSheet> + ); + + if ( ! url ) { + return ( + <View style={ { flex: 1 } } > + <MediaPlaceholder + mediaType={ MEDIA_TYPE_IMAGE } + onSelectURL={ this.onSelectMediaUploadOption } + icon={ this.getIcon( false ) } + onFocus={ this.props.onFocus } + /> + </View> + ); + } + + const imageContainerHeight = Dimensions.get( 'window' ).width / IMAGE_ASPECT_RATIO; + const getImageComponent = ( openMediaOptions, getMediaOptions ) => ( + <TouchableWithoutFeedback + accessible={ ! isSelected } + onPress={ this.onImagePressed } + onLongPress={ openMediaOptions } + disabled={ ! isSelected } + > + <View style={ { flex: 1 } }> + { getInspectorControls() } + { getMediaOptions() } + { ( ! this.state.isCaptionSelected ) && + getToolbarEditButton( openMediaOptions ) + } + <InspectorControls> + <ToolbarButton + title={ __( 'Image Settings' ) } + icon="admin-generic" + onClick={ onImageSettingsButtonPressed } + /> + </InspectorControls> + <MediaUploadProgress + height={ height } + width={ width } + coverUrl={ url } + mediaId={ id } + onUpdateMediaProgress={ this.updateMediaProgress } + onFinishMediaUploadWithSuccess={ this.finishMediaUploadWithSuccess } + onFinishMediaUploadWithFailure={ this.finishMediaUploadWithFailure } + onMediaUploadStateReset={ this.mediaUploadStateReset } + renderContent={ ( { isUploadInProgress, isUploadFailed, finalWidth, finalHeight, imageWidthWithinContainer, retryMessage } ) => { + const opacity = isUploadInProgress ? 0.3 : 1; + const icon = this.getIcon( isUploadFailed ); + + const iconContainer = ( + <View style={ styles.modalIcon }> + { icon } + </View> + ); + + return ( + <View style={ { flex: 1 } } > + { ! imageWidthWithinContainer && + <View style={ [ styles.imageContainer, { height: imageContainerHeight } ] } > + { this.getIcon( false ) } + </View> } + <ImageBackground + accessible={ true } + disabled={ ! isSelected } + accessibilityLabel={ alt } + accessibilityHint={ __( 'Double tap and hold to edit' ) } + accessibilityRole={ 'imagebutton' } + style={ { width: finalWidth, height: finalHeight, opacity } } + resizeMethod="scale" + source={ { uri: url } } + key={ url } + > + { isUploadFailed && + <View style={ [ styles.imageContainer, { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' } ] } > + { iconContainer } + <Text style={ styles.uploadFailedText }>{ retryMessage }</Text> + </View> + } + </ImageBackground> + </View> + ); + } } + /> + <Caption + clientId={ this.props.clientId } + isSelected={ this.state.isCaptionSelected } + accessible={ true } + accessibilityLabelCreator={ ( caption ) => + isEmpty( caption ) ? + /* translators: accessibility text. Empty image caption. */ + ( 'Image caption. Empty' ) : + sprintf( + /* translators: accessibility text. %s: image caption. */ + __( 'Image caption. %s' ), + caption ) + } + onFocus={ this.onFocusCaption } + onBlur={ this.props.onBlur } // always assign onBlur as props + /> + </View> + </TouchableWithoutFeedback> + ); + + return ( + <MediaUpload mediaType={ MEDIA_TYPE_IMAGE } + onSelectURL={ this.onSelectMediaUploadOption } + render={ ( { open, getMediaOptions } ) => { + return getImageComponent( open, getMediaOptions ); + } } + /> + ); + } +} + +export default ImageEdit; diff --git a/gb-src/packages/block-library/src/image/editor.scss b/gb-src/packages/block-library/src/image/editor.scss new file mode 100644 index 0000000000000..6f4438f6309e4 --- /dev/null +++ b/gb-src/packages/block-library/src/image/editor.scss @@ -0,0 +1,130 @@ +.wp-block-image { + position: relative; + + // This resets the intrinsic margin on the figure in non-floated, wide, and full-wide alignments. + margin-left: 0; + margin-right: 0; + + &.is-transient img { + opacity: 0.3; + } + + figcaption img { + display: inline; + } + + // Shown while image is being uploaded + .components-spinner { + position: absolute; + top: 50%; + left: 50%; + margin-top: -9px; + margin-left: -9px; + } +} + +// This is necessary for the editor resize handles to accurately work on a non-floated, non-resized, small image. +.wp-block-image .components-resizable-box__container { + display: inline-block; + + img { + display: block; + width: 100%; + } +} + +// Ensure the resize handles are visible when the image is focused. +.wp-block-image.is-focused .components-resizable-box__handle { + display: block; + z-index: z-index(".block-library-image__resize-handlers"); +} + +.block-editor-block-list__block[data-type="core/image"][data-align="center"] { + .wp-block-image { + margin-left: auto; + margin-right: auto; + } + + &[data-resized="false"] .wp-block-image > div { + margin-left: auto; + margin-right: auto; + } +} + +.edit-post-sidebar .block-library-image__dimensions { + margin-bottom: 1em; + + .block-library-image__dimensions__row { + display: flex; + justify-content: space-between; + + .block-library-image__dimensions__width, + .block-library-image__dimensions__height { + margin-bottom: 0.5em; + + // Fix the text and placeholder text being misaligned in Safari + input { + line-height: 1.25; + } + } + + .block-library-image__dimensions__width { + margin-right: 5px; + } + + .block-library-image__dimensions__height { + margin-left: 5px; + } + } +} + +.block-editor-block-list__block[data-type="core/image"] .block-editor-block-toolbar .block-editor-url-input__button-modal { + position: absolute; + left: 0; + right: 0; + margin: -$border-width 0; + + @include break-small() { + margin: -$border-width; + } +} + +// Although the float markup is different in the editor compared to the frontend, +// this CSS uses the same technique to allow floats in a wide images context. +// That is, the block retains its centering and max-width, and a child inside +// is floated instead of the block itself. +[data-type="core/image"][data-align="center"], +[data-type="core/image"][data-align="left"], +[data-type="core/image"][data-align="right"] { + .block-editor-block-list__block-edit { + figure { + margin: 0; + display: table; + } + + // This maps to the figcaption on the frontend. + .block-editor-rich-text { + display: table-caption; + caption-side: bottom; + } + } +} + +[data-type="core/image"][data-align="wide"], +[data-type="core/image"][data-align="full"] { + figure img { + width: 100%; + } +} + +// This is similar to above but for resized unfloated images only, where the markup is different. +[data-type="core/image"] .block-editor-block-list__block-edit figure.is-resized { + margin: 0; + display: table; + + // This maps to the figcaption on the frontend. + .block-editor-rich-text { + display: table-caption; + caption-side: bottom; + } +} diff --git a/gb-src/packages/block-library/src/image/icon-retry.native.js b/gb-src/packages/block-library/src/image/icon-retry.native.js new file mode 100644 index 0000000000000..bdd40f69efd64 --- /dev/null +++ b/gb-src/packages/block-library/src/image/icon-retry.native.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /><Path d="M0 0h24v24H0z" fill={ 'none' } /></SVG>; diff --git a/gb-src/packages/block-library/src/image/icon.js b/gb-src/packages/block-library/src/image/icon.js new file mode 100644 index 0000000000000..b029bab8fbe98 --- /dev/null +++ b/gb-src/packages/block-library/src/image/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="m19 5v14h-14v-14h14m0-2h-14c-1.1 0-2 0.9-2 2v14c0 1.1 0.9 2 2 2h14c1.1 0 2-0.9 2-2v-14c0-1.1-0.9-2-2-2z" /><Path d="m14.14 11.86l-3 3.87-2.14-2.59-3 3.86h12l-3.86-5.14z" /></SVG>; diff --git a/gb-src/packages/block-library/src/image/image-size.js b/gb-src/packages/block-library/src/image/image-size.js new file mode 100644 index 0000000000000..bc7ebaeaa59b4 --- /dev/null +++ b/gb-src/packages/block-library/src/image/image-size.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withGlobalEvents } from '@wordpress/compose'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { calculatePreferedImageSize } from './utils'; + +class ImageSize extends Component { + constructor() { + super( ...arguments ); + this.state = { + width: undefined, + height: undefined, + }; + this.bindContainer = this.bindContainer.bind( this ); + this.calculateSize = this.calculateSize.bind( this ); + } + + bindContainer( ref ) { + this.container = ref; + } + + componentDidUpdate( prevProps ) { + if ( this.props.src !== prevProps.src ) { + this.setState( { + width: undefined, + height: undefined, + } ); + this.fetchImageSize(); + } + + if ( this.props.dirtynessTrigger !== prevProps.dirtynessTrigger ) { + this.calculateSize(); + } + } + + componentDidMount() { + this.fetchImageSize(); + } + + componentWillUnmount() { + if ( this.image ) { + this.image.onload = noop; + } + } + + fetchImageSize() { + this.image = new window.Image(); + this.image.onload = this.calculateSize; + this.image.src = this.props.src; + } + + calculateSize() { + const { width, height } = calculatePreferedImageSize( this.image, this.container ); + this.setState( { width, height } ); + } + + render() { + const sizes = { + imageWidth: this.image && this.image.width, + imageHeight: this.image && this.image.height, + containerWidth: this.container && this.container.clientWidth, + containerHeight: this.container && this.container.clientHeight, + imageWidthWithinContainer: this.state.width, + imageHeightWithinContainer: this.state.height, + }; + return ( + <div ref={ this.bindContainer }> + { this.props.children( sizes ) } + </div> + ); + } +} + +export default withGlobalEvents( { + resize: 'calculateSize', +} )( ImageSize ); diff --git a/gb-src/packages/block-library/src/image/image-size.native.js b/gb-src/packages/block-library/src/image/image-size.native.js new file mode 100644 index 0000000000000..a337a5d14548c --- /dev/null +++ b/gb-src/packages/block-library/src/image/image-size.native.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * External dependencies + */ +import { View, Image } from 'react-native'; + +/** + * Internal dependencies + */ +import { calculatePreferedImageSize } from './utils'; + +class ImageSize extends Component { + constructor() { + super( ...arguments ); + this.state = { + width: undefined, + height: undefined, + }; + this.onLayout = this.onLayout.bind( this ); + } + + componentDidUpdate( prevProps ) { + if ( this.props.src !== prevProps.src ) { + this.image = {}; + + this.setState( { + width: undefined, + height: undefined, + } ); + this.fetchImageSize(); + } + + if ( this.props.dirtynessTrigger !== prevProps.dirtynessTrigger ) { + this.calculateSize(); + } + } + + componentDidMount() { + this.fetchImageSize(); + } + + fetchImageSize() { + Image.getSize( this.props.src, ( width, height ) => { + this.image = { width, height }; + this.calculateSize(); + } ); + } + + calculateSize() { + if ( this.image === undefined || this.container === undefined ) { + return; + } + const { width, height } = calculatePreferedImageSize( this.image, this.container ); + this.setState( { width, height } ); + } + + onLayout( event ) { + const { width, height } = event.nativeEvent.layout; + this.container = { + clientWidth: width, + clientHeight: height, + }; + this.calculateSize(); + } + + render() { + const sizes = { + imageWidth: this.image && this.image.width, + imageHeight: this.image && this.image.height, + containerWidth: this.container && this.container.width, + containerHeight: this.container && this.container.height, + imageWidthWithinContainer: this.state.width, + imageHeightWithinContainer: this.state.height, + }; + return ( + <View onLayout={ this.onLayout }> + { this.props.children( sizes ) } + </View> + ); + } +} + +export default ImageSize; diff --git a/gb-src/packages/block-library/src/image/index.js b/gb-src/packages/block-library/src/image/index.js new file mode 100644 index 0000000000000..78c37538d5bff --- /dev/null +++ b/gb-src/packages/block-library/src/image/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Image' ), + description: __( 'Insert an image to make a visual statement.' ), + icon, + keywords: [ + 'img', // "img" is not translated as it is intended to reflect the HTML <img> tag. + __( 'photo' ), + ], + example: { + attributes: { + sizeSlug: 'large', + url: 'https://s.w.org/images/core/5.3/MtBlanc1.jpg', + caption: __( 'Mont Blanc appears—still, snowy, and serene.' ), + }, + }, + styles: [ + { name: 'default', label: _x( 'Default', 'block style' ), isDefault: true }, + { name: 'circle-mask', label: _x( 'Circle Mask', 'block style' ) }, + ], + transforms, + getEditWrapperProps( attributes ) { + const { align, width } = attributes; + if ( 'left' === align || 'center' === align || 'right' === align || 'wide' === align || 'full' === align ) { + return { 'data-align': align, 'data-resized': !! width }; + } + }, + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/image/index.native.js b/gb-src/packages/block-library/src/image/index.native.js new file mode 100644 index 0000000000000..d7e0d17b53a9c --- /dev/null +++ b/gb-src/packages/block-library/src/image/index.native.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + __experimentalGetAccessibilityLabel( attributes ) { + const { caption, alt, url } = attributes; + + if ( ! url ) { + return __( 'Empty' ); + } + + if ( ! alt ) { + return caption || ''; + } + + // This is intended to be read by a screen reader. + // A period simply means a pause, no need to translate it. + return alt + ( caption ? '. ' + caption : '' ); + }, +}; diff --git a/gb-src/packages/block-library/src/image/media-upload-progress.native.js b/gb-src/packages/block-library/src/image/media-upload-progress.native.js new file mode 100644 index 0000000000000..18856fb596606 --- /dev/null +++ b/gb-src/packages/block-library/src/image/media-upload-progress.native.js @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import React from 'react'; +import { View } from 'react-native'; +import { + subscribeMediaUpload, +} from 'react-native-gutenberg-bridge'; + +/** + * WordPress dependencies + */ +import { + Spinner, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ImageSize from './image-size'; +import styles from './styles.scss'; + +export const MEDIA_UPLOAD_STATE_UPLOADING = 1; +export const MEDIA_UPLOAD_STATE_SUCCEEDED = 2; +export const MEDIA_UPLOAD_STATE_FAILED = 3; +export const MEDIA_UPLOAD_STATE_RESET = 4; + +export class MediaUploadProgress extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + progress: 0, + isUploadInProgress: false, + isUploadFailed: false, + }; + + this.mediaUpload = this.mediaUpload.bind( this ); + } + + componentDidMount() { + this.addMediaUploadListener(); + } + + componentWillUnmount() { + this.removeMediaUploadListener(); + } + + mediaUpload( payload ) { + const { mediaId } = this.props; + + if ( payload.mediaId !== mediaId ) { + return; + } + + switch ( payload.state ) { + case MEDIA_UPLOAD_STATE_UPLOADING: + this.updateMediaProgress( payload ); + break; + case MEDIA_UPLOAD_STATE_SUCCEEDED: + this.finishMediaUploadWithSuccess( payload ); + break; + case MEDIA_UPLOAD_STATE_FAILED: + this.finishMediaUploadWithFailure( payload ); + break; + case MEDIA_UPLOAD_STATE_RESET: + this.mediaUploadStateReset( payload ); + break; + } + } + + updateMediaProgress( payload ) { + this.setState( { progress: payload.progress, isUploadInProgress: true, isUploadFailed: false } ); + if ( this.props.onUpdateMediaProgress ) { + this.props.onUpdateMediaProgress( payload ); + } + } + + finishMediaUploadWithSuccess( payload ) { + this.setState( { isUploadInProgress: false } ); + if ( this.props.onFinishMediaUploadWithSuccess ) { + this.props.onFinishMediaUploadWithSuccess( payload ); + } + } + + finishMediaUploadWithFailure( payload ) { + this.setState( { isUploadInProgress: false, isUploadFailed: true } ); + if ( this.props.onFinishMediaUploadWithFailure ) { + this.props.onFinishMediaUploadWithFailure( payload ); + } + } + + mediaUploadStateReset( payload ) { + this.setState( { isUploadInProgress: false, isUploadFailed: false } ); + if ( this.props.onMediaUploadStateReset ) { + this.props.onMediaUploadStateReset( payload ); + } + } + + addMediaUploadListener() { + //if we already have a subscription not worth doing it again + if ( this.subscriptionParentMediaUpload ) { + return; + } + this.subscriptionParentMediaUpload = subscribeMediaUpload( ( payload ) => { + this.mediaUpload( payload ); + } ); + } + + removeMediaUploadListener() { + if ( this.subscriptionParentMediaUpload ) { + this.subscriptionParentMediaUpload.remove(); + } + } + + render() { + const { coverUrl, width, height } = this.props; + const { isUploadInProgress, isUploadFailed } = this.state; + const showSpinner = this.state.isUploadInProgress; + const progress = this.state.progress * 100; + const retryMessage = __( 'Failed to insert media.\nPlease tap for options.' ); + + return ( + <View style={ styles.mediaUploadProgress }> + { showSpinner && <Spinner progress={ progress } /> } + { coverUrl && + <ImageSize src={ coverUrl } > + { ( sizes ) => { + const { + imageWidthWithinContainer, + imageHeightWithinContainer, + } = sizes; + + let finalHeight = imageHeightWithinContainer; + if ( height > 0 && height < imageHeightWithinContainer ) { + finalHeight = height; + } + + let finalWidth = imageWidthWithinContainer; + if ( width > 0 && width < imageWidthWithinContainer ) { + finalWidth = width; + } + return ( this.props.renderContent( { + isUploadInProgress, + isUploadFailed, + finalWidth, + finalHeight, + imageWidthWithinContainer, + retryMessage, + } ) ); + } } + </ImageSize> + } + { ! coverUrl && this.props.renderContent( { + isUploadInProgress, + isUploadFailed, + retryMessage, + } ) } + </View> + ); + } +} + +export default MediaUploadProgress; diff --git a/gb-src/packages/block-library/src/image/save.js b/gb-src/packages/block-library/src/image/save.js new file mode 100644 index 0000000000000..01aff39769152 --- /dev/null +++ b/gb-src/packages/block-library/src/image/save.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + url, + alt, + caption, + align, + href, + rel, + linkClass, + width, + height, + id, + linkTarget, + sizeSlug, + } = attributes; + + const classes = classnames( { + [ `align${ align }` ]: align, + [ `size-${ sizeSlug }` ]: sizeSlug, + 'is-resized': width || height, + } ); + + const image = ( + <img + src={ url } + alt={ alt } + className={ id ? `wp-image-${ id }` : null } + width={ width } + height={ height } + /> + ); + + const figure = ( + <> + { href ? ( + <a + className={ linkClass } + href={ href } + target={ linkTarget } + rel={ rel } + > + { image } + </a> + ) : image } + { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } + </> + ); + + if ( 'left' === align || 'right' === align || 'center' === align ) { + return ( + <div> + <figure className={ classes }> + { figure } + </figure> + </div> + ); + } + + return ( + <figure className={ classes }> + { figure } + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/image/style.scss b/gb-src/packages/block-library/src/image/style.scss new file mode 100644 index 0000000000000..01356391e9cfa --- /dev/null +++ b/gb-src/packages/block-library/src/image/style.scss @@ -0,0 +1,84 @@ +.wp-block-image { + max-width: 100%; + + // The image block is in a `figure` element, and many themes zero this out. + // This rule explicitly sets it, to ensure at least some bottom-margin in the flow. + margin-bottom: 1em; + + img { + max-width: 100%; + } + + &.aligncenter { + text-align: center; + } + + &.alignfull img, + &.alignwide img { + width: 100%; + } + + // Floats get an extra wrapping <div> element, so the <figure> becomes a child. + .alignleft, + .alignright, + .aligncenter, + &.is-resized { + display: table; + + // The figure is born with left and right margin. + // We remove this by default, and then customize it for left, right, and center. + margin-left: 0; + margin-right: 0; + + > figcaption { + display: table-caption; + caption-side: bottom; + } + } + + .alignleft { + /*rtl:ignore*/ + float: left; + /*rtl:ignore*/ + margin-right: 1em; + } + + .alignright { + /*rtl:ignore*/ + float: right; + /*rtl:ignore*/ + margin-left: 1em; + } + + .aligncenter { + margin-left: auto; + margin-right: auto; + } + + // Supply caption styles to images, even if the theme hasn't opted in. + // Reason being: the new markup, <figcaptions>, are not likely to be styled in the majority of existing themes, + // so we supply the styles so as to not appear broken or unstyled in those themes. + figcaption { + @include caption-style(); + } +} + +// Variations +.is-style-circle-mask img { + // We use an absolute pixel to prevent the oval shape that a value of 50% would give + // to rectangular images. A pill-shape is better than otherwise. + border-radius: 9999px; + + // If a browser supports it, we will switch to using a circular SVG mask. + // The stylelint override is necessary to use the SVG inline here. + @supports (mask-image: none) or (-webkit-mask-image: none) { + /* stylelint-disable */ + mask-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50"/></svg>'); + /* stylelint-enable */ + mask-mode: alpha; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + border-radius: none; + } +} diff --git a/gb-src/packages/block-library/src/image/styles.native.scss b/gb-src/packages/block-library/src/image/styles.native.scss new file mode 100644 index 0000000000000..81578bd734ba3 --- /dev/null +++ b/gb-src/packages/block-library/src/image/styles.native.scss @@ -0,0 +1,47 @@ +// @format + +.imageContainer { + justify-content: center; + align-items: center; + background-color: $gray-lighten-30; +} + +.uploadFailedText { + color: #fff; + font-size: 14; + margin-top: 5; +} + +.uploadFailedContainer { + position: absolute; + flex-direction: column; + align-items: center; +} + +.clearSettingsButton { + color: $alert-red; +} + +.mediaUploadProgress { + flex: 1; + background-color: $gray-lighten-30; +} + +.modalIcon { + width: 80px; + height: 80px; + justify-content: center; + align-items: center; +} + +.iconRetry { + fill: #fff; + width: 100%; + height: 100%; +} + +.icon { + fill: $gray-dark; + width: 100%; + height: 100%; +} diff --git a/gb-src/packages/block-library/src/image/test/edit.js b/gb-src/packages/block-library/src/image/test/edit.js new file mode 100644 index 0000000000000..506a3358a54e4 --- /dev/null +++ b/gb-src/packages/block-library/src/image/test/edit.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { ImageEdit } from '../edit'; + +describe( 'core/image/edit', () => { + describe( 'onSelectImage', () => { + test( 'should reset dimensions when changing the image and keep them on selecting the same image', () => { + const attributes = { + id: 1, + url: 'http://www.example.com/myimage.jpeg', + alt: 'alt1', + }; + const setAttributes = jest.fn( () => {} ); + const testRenderer = TestRenderer.create( + <ImageEdit attributes={ attributes } setAttributes={ setAttributes } /> + ); + const instance = testRenderer.getInstance(); + + instance.onSelectImage( { + id: 1, + url: 'http://www.example.com/myimage.jpeg', + alt: 'alt2', + } ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 1, + url: 'http://www.example.com/myimage.jpeg', + alt: 'alt2', + } ); + instance.onSelectImage( { + id: 2, + url: 'http://www.example.com/myimage.jpeg', + alt: 'alt2', + } ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + url: 'http://www.example.com/myimage.jpeg', + alt: 'alt2', + sizeSlug: 'large', + width: undefined, + height: undefined, + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/image/test/media-upload-progress.native.js b/gb-src/packages/block-library/src/image/test/media-upload-progress.native.js new file mode 100644 index 0000000000000..042f571aeefe5 --- /dev/null +++ b/gb-src/packages/block-library/src/image/test/media-upload-progress.native.js @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { + sendMediaUpload, +} from 'react-native-gutenberg-bridge'; + +/** + * Internal dependencies + */ +import { + MediaUploadProgress, + MEDIA_UPLOAD_STATE_UPLOADING, + MEDIA_UPLOAD_STATE_SUCCEEDED, + MEDIA_UPLOAD_STATE_FAILED, + MEDIA_UPLOAD_STATE_RESET, +} from '../media-upload-progress'; + +jest.mock( 'react-native-gutenberg-bridge', () => { + const callUploadCallback = ( payload ) => { + this.uploadCallBack( payload ); + }; + const subscribeMediaUpload = ( callback ) => { + this.uploadCallBack = callback; + }; + return { + subscribeMediaUpload, + sendMediaUpload: callUploadCallback, + }; +} ); + +const MEDIA_ID = 123; + +describe( 'MediaUploadProgress component', () => { + it( 'renders without crashing', () => { + const wrapper = shallow( + <MediaUploadProgress renderContent={ () => {} } /> + ); + expect( wrapper ).toBeTruthy(); + } ); + + it( 'listens media upload progress', () => { + const progress = 10; + const payload = { state: MEDIA_UPLOAD_STATE_UPLOADING, mediaId: MEDIA_ID, progress }; + + const onUpdateMediaProgress = jest.fn(); + + const wrapper = shallow( + <MediaUploadProgress + onUpdateMediaProgress={ onUpdateMediaProgress } + mediaId={ MEDIA_ID } + renderContent={ () => {} } + /> + ); + + sendMediaUpload( payload ); + + expect( wrapper.instance().state.progress ).toEqual( progress ); + expect( wrapper.instance().state.isUploadInProgress ).toEqual( true ); + expect( wrapper.instance().state.isUploadFailed ).toEqual( false ); + expect( onUpdateMediaProgress ).toHaveBeenCalledTimes( 1 ); + expect( onUpdateMediaProgress ).toHaveBeenCalledWith( payload ); + } ); + + it( 'does not get affected by unrelated media uploads', () => { + const payload = { state: MEDIA_UPLOAD_STATE_UPLOADING, mediaId: 1, progress: 20 }; + const onUpdateMediaProgress = jest.fn(); + const wrapper = shallow( + <MediaUploadProgress + onUpdateMediaProgress={ onUpdateMediaProgress } + mediaId={ MEDIA_ID } + renderContent={ () => {} } + /> + ); + + sendMediaUpload( payload ); + + expect( wrapper.instance().state.progress ).toEqual( 0 ); + expect( onUpdateMediaProgress ).toHaveBeenCalledTimes( 0 ); + } ); + + it( 'listens media upload success', () => { + const progress = 10; + const payloadSuccess = { state: MEDIA_UPLOAD_STATE_SUCCEEDED, mediaId: MEDIA_ID }; + const payloadUploading = { state: MEDIA_UPLOAD_STATE_UPLOADING, mediaId: MEDIA_ID, progress }; + + const onFinishMediaUploadWithSuccess = jest.fn(); + + const wrapper = shallow( + <MediaUploadProgress + onFinishMediaUploadWithSuccess={ onFinishMediaUploadWithSuccess } + mediaId={ MEDIA_ID } + renderContent={ () => {} } + /> + ); + + sendMediaUpload( payloadUploading ); + + expect( wrapper.instance().state.progress ).toEqual( progress ); + + sendMediaUpload( payloadSuccess ); + + expect( wrapper.instance().state.isUploadInProgress ).toEqual( false ); + expect( onFinishMediaUploadWithSuccess ).toHaveBeenCalledTimes( 1 ); + expect( onFinishMediaUploadWithSuccess ).toHaveBeenCalledWith( payloadSuccess ); + } ); + + it( 'listens media upload fail', () => { + const progress = 10; + const payloadFail = { state: MEDIA_UPLOAD_STATE_FAILED, mediaId: MEDIA_ID }; + const payloadUploading = { state: MEDIA_UPLOAD_STATE_UPLOADING, mediaId: MEDIA_ID, progress }; + + const onFinishMediaUploadWithFailure = jest.fn(); + + const wrapper = shallow( + <MediaUploadProgress + onFinishMediaUploadWithFailure={ onFinishMediaUploadWithFailure } + mediaId={ MEDIA_ID } + renderContent={ () => {} } + /> + ); + + sendMediaUpload( payloadUploading ); + + expect( wrapper.instance().state.progress ).toEqual( progress ); + + sendMediaUpload( payloadFail ); + + expect( wrapper.instance().state.isUploadInProgress ).toEqual( false ); + expect( wrapper.instance().state.isUploadFailed ).toEqual( true ); + expect( onFinishMediaUploadWithFailure ).toHaveBeenCalledTimes( 1 ); + expect( onFinishMediaUploadWithFailure ).toHaveBeenCalledWith( payloadFail ); + } ); + + it( 'listens media upload reset', () => { + const progress = 10; + const payloadReset = { state: MEDIA_UPLOAD_STATE_RESET, mediaId: MEDIA_ID }; + const payloadUploading = { state: MEDIA_UPLOAD_STATE_UPLOADING, mediaId: MEDIA_ID, progress }; + + const onMediaUploadStateReset = jest.fn(); + + const wrapper = shallow( + <MediaUploadProgress + onMediaUploadStateReset={ onMediaUploadStateReset } + mediaId={ MEDIA_ID } + renderContent={ () => {} } + /> + ); + + sendMediaUpload( payloadUploading ); + + expect( wrapper.instance().state.progress ).toEqual( progress ); + + sendMediaUpload( payloadReset ); + + expect( wrapper.instance().state.isUploadInProgress ).toEqual( false ); + expect( wrapper.instance().state.isUploadFailed ).toEqual( false ); + expect( onMediaUploadStateReset ).toHaveBeenCalledTimes( 1 ); + expect( onMediaUploadStateReset ).toHaveBeenCalledWith( payloadReset ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/image/test/transforms.js b/gb-src/packages/block-library/src/image/test/transforms.js new file mode 100644 index 0000000000000..14f2f8dc42580 --- /dev/null +++ b/gb-src/packages/block-library/src/image/test/transforms.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { stripFirstImage } from '../transforms'; + +describe( 'stripFirstImage', () => { + test( 'should do nothing if no image is present', () => { + expect( stripFirstImage( {}, { shortcode: { content: '' } } ) ).toEqual( '' ); + expect( stripFirstImage( {}, { shortcode: { content: 'Tucson' } } ) ).toEqual( 'Tucson' ); + expect( stripFirstImage( {}, { shortcode: { content: '<em>Tucson</em>' } } ) ).toEqual( '<em>Tucson</em>' ); + } ); + + test( 'should strip out image when leading as expected', () => { + expect( stripFirstImage( {}, { shortcode: { content: '<img>' } } ) ).toEqual( '' ); + expect( stripFirstImage( {}, { shortcode: { content: '<img>Image!' } } ) ).toEqual( 'Image!' ); + expect( stripFirstImage( {}, { shortcode: { content: '<img src="image.png">Image!' } } ) ).toEqual( 'Image!' ); + } ); + + test( 'should strip out image when not in leading position as expected', () => { + expect( stripFirstImage( {}, { shortcode: { content: 'Before<img>' } } ) ).toEqual( 'Before' ); + expect( stripFirstImage( {}, { shortcode: { content: 'Before<img>Image!' } } ) ).toEqual( 'BeforeImage!' ); + expect( stripFirstImage( {}, { shortcode: { content: 'Before<img src="image.png">Image!' } } ) ).toEqual( 'BeforeImage!' ); + } ); + + test( 'should strip out only the first of many images', () => { + expect( stripFirstImage( {}, { shortcode: { content: '<img><img>' } } ) ).toEqual( '<img>' ); + } ); + + test( 'should strip out the first image and its wrapping parents', () => { + expect( stripFirstImage( {}, { shortcode: { content: '<p><a><img></a></p><p><img></p>' } } ) ).toEqual( '<p><img></p>' ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/image/theme.scss b/gb-src/packages/block-library/src/image/theme.scss new file mode 100644 index 0000000000000..95ed74e1ee2ff --- /dev/null +++ b/gb-src/packages/block-library/src/image/theme.scss @@ -0,0 +1,3 @@ +.wp-block-image figcaption { + @include caption-style-theme(); +} diff --git a/gb-src/packages/block-library/src/image/transforms.js b/gb-src/packages/block-library/src/image/transforms.js new file mode 100644 index 0000000000000..2af42cc2244e9 --- /dev/null +++ b/gb-src/packages/block-library/src/image/transforms.js @@ -0,0 +1,161 @@ +/** + * WordPress dependencies + */ +import { createBlobURL } from '@wordpress/blob'; +import { + createBlock, + getBlockAttributes, + getPhrasingContentSchema, +} from '@wordpress/blocks'; + +export function stripFirstImage( attributes, { shortcode } ) { + const { body } = document.implementation.createHTMLDocument( '' ); + + body.innerHTML = shortcode.content; + + let nodeToRemove = body.querySelector( 'img' ); + + // if an image has parents, find the topmost node to remove + while ( nodeToRemove && nodeToRemove.parentNode && nodeToRemove.parentNode !== body ) { + nodeToRemove = nodeToRemove.parentNode; + } + + if ( nodeToRemove ) { + nodeToRemove.parentNode.removeChild( nodeToRemove ); + } + + return body.innerHTML.trim(); +} + +function getFirstAnchorAttributeFormHTML( html, attributeName ) { + const { body } = document.implementation.createHTMLDocument( '' ); + + body.innerHTML = html; + + const { firstElementChild } = body; + + if ( + firstElementChild && + firstElementChild.nodeName === 'A' + ) { + return firstElementChild.getAttribute( attributeName ) || undefined; + } +} + +const imageSchema = { + img: { + attributes: [ 'src', 'alt' ], + classes: [ 'alignleft', 'aligncenter', 'alignright', 'alignnone', /^wp-image-\d+$/ ], + }, +}; + +const schema = { + figure: { + require: [ 'img' ], + children: { + ...imageSchema, + a: { + attributes: [ 'href', 'rel', 'target' ], + children: imageSchema, + }, + figcaption: { + children: getPhrasingContentSchema(), + }, + }, + }, +}; + +const transforms = { + from: [ + { + type: 'raw', + isMatch: ( node ) => node.nodeName === 'FIGURE' && !! node.querySelector( 'img' ), + schema, + transform: ( node ) => { + // Search both figure and image classes. Alignment could be + // set on either. ID is set on the image. + const className = node.className + ' ' + node.querySelector( 'img' ).className; + const alignMatches = /(?:^|\s)align(left|center|right)(?:$|\s)/.exec( className ); + const align = alignMatches ? alignMatches[ 1 ] : undefined; + const idMatches = /(?:^|\s)wp-image-(\d+)(?:$|\s)/.exec( className ); + const id = idMatches ? Number( idMatches[ 1 ] ) : undefined; + const anchorElement = node.querySelector( 'a' ); + const linkDestination = anchorElement && anchorElement.href ? 'custom' : undefined; + const href = anchorElement && anchorElement.href ? anchorElement.href : undefined; + const rel = anchorElement && anchorElement.rel ? anchorElement.rel : undefined; + const linkClass = anchorElement && anchorElement.className ? anchorElement.className : undefined; + const attributes = getBlockAttributes( 'core/image', node.outerHTML, { align, id, linkDestination, href, rel, linkClass } ); + return createBlock( 'core/image', attributes ); + }, + }, + { + type: 'files', + isMatch( files ) { + return files.length === 1 && files[ 0 ].type.indexOf( 'image/' ) === 0; + }, + transform( files ) { + const file = files[ 0 ]; + // We don't need to upload the media directly here + // It's already done as part of the `componentDidMount` + // int the image block + return createBlock( 'core/image', { + url: createBlobURL( file ), + } ); + }, + }, + { + type: 'shortcode', + tag: 'caption', + attributes: { + url: { + type: 'string', + source: 'attribute', + attribute: 'src', + selector: 'img', + }, + alt: { + type: 'string', + source: 'attribute', + attribute: 'alt', + selector: 'img', + }, + caption: { + shortcode: stripFirstImage, + }, + href: { + shortcode: ( attributes, { shortcode } ) => { + return getFirstAnchorAttributeFormHTML( shortcode.content, 'href' ); + }, + }, + rel: { + shortcode: ( attributes, { shortcode } ) => { + return getFirstAnchorAttributeFormHTML( shortcode.content, 'rel' ); + }, + }, + linkClass: { + shortcode: ( attributes, { shortcode } ) => { + return getFirstAnchorAttributeFormHTML( shortcode.content, 'class' ); + }, + }, + id: { + type: 'number', + shortcode: ( { named: { id } } ) => { + if ( ! id ) { + return; + } + + return parseInt( id.replace( 'attachment_', '' ), 10 ); + }, + }, + align: { + type: 'string', + shortcode: ( { named: { align = 'alignnone' } } ) => { + return align.replace( 'align', '' ); + }, + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/image/utils.js b/gb-src/packages/block-library/src/image/utils.js new file mode 100644 index 0000000000000..32bdb9d71564c --- /dev/null +++ b/gb-src/packages/block-library/src/image/utils.js @@ -0,0 +1,9 @@ + +export function calculatePreferedImageSize( image, container ) { + const maxWidth = container.clientWidth; + const exceedMaxWidth = image.width > maxWidth; + const ratio = image.height / image.width; + const width = exceedMaxWidth ? maxWidth : image.width; + const height = exceedMaxWidth ? maxWidth * ratio : image.height; + return { width, height }; +} diff --git a/gb-src/packages/block-library/src/index.js b/gb-src/packages/block-library/src/index.js new file mode 100644 index 0000000000000..e49f68553b0a6 --- /dev/null +++ b/gb-src/packages/block-library/src/index.js @@ -0,0 +1,175 @@ +/** + * WordPress dependencies + */ +import '@wordpress/core-data'; +import '@wordpress/block-editor'; +import '@wordpress/editor'; +import { + registerBlockType, + setDefaultBlockName, + setFreeformContentHandlerName, + setUnregisteredTypeHandlerName, + setGroupingBlockName, + unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import * as paragraph from './paragraph'; +import * as image from './image'; +import * as heading from './heading'; +import * as quote from './quote'; +import * as gallery from './gallery'; +import * as archives from './archives'; +import * as audio from './audio'; +import * as button from './button'; +import * as calendar from './calendar'; +import * as categories from './categories'; +import * as code from './code'; +import * as columns from './columns'; +import * as column from './column'; +import * as cover from './cover'; +import * as embed from './embed'; +import * as file from './file'; +import * as html from './html'; +import * as mediaText from './media-text'; +import * as navigationMenu from './navigation-menu'; +import * as navigationMenuItem from './navigation-menu-item'; +import * as latestComments from './latest-comments'; +import * as latestPosts from './latest-posts'; +import * as legacyWidget from './legacy-widget'; +import * as list from './list'; +import * as missing from './missing'; +import * as more from './more'; +import * as nextpage from './nextpage'; +import * as preformatted from './preformatted'; +import * as pullquote from './pullquote'; +import * as reusableBlock from './block'; +import * as rss from './rss'; +import * as search from './search'; +import * as group from './group'; +import * as separator from './separator'; +import * as shortcode from './shortcode'; +import * as spacer from './spacer'; +import * as subhead from './subhead'; +import * as table from './table'; +import * as textColumns from './text-columns'; +import * as verse from './verse'; +import * as video from './video'; +import * as tagCloud from './tag-cloud'; +import * as classic from './classic'; +import * as socialLinks from './social-links'; +import * as socialLink from './social-link'; + +/** + * Function to register an individual block. + * + * @param {Object} block The block to be registered. + * + */ +const registerBlock = ( block ) => { + if ( ! block ) { + return; + } + const { metadata, settings, name } = block; + if ( metadata ) { + unstable__bootstrapServerSideBlockDefinitions( { [ name ]: metadata } ); + } + registerBlockType( name, settings ); +}; + +/** + * Function to register core blocks provided by the block editor. + * + * @example + * ```js + * import { registerCoreBlocks } from '@wordpress/block-library'; + * + * registerCoreBlocks(); + * ``` + */ +export const registerCoreBlocks = () => { + [ + // Common blocks are grouped at the top to prioritize their display + // in various contexts — like the inserter and auto-complete components. + paragraph, + image, + heading, + gallery, + list, + quote, + + // Register all remaining core blocks. + shortcode, + archives, + audio, + button, + calendar, + categories, + code, + columns, + column, + cover, + embed, + ...embed.common, + ...embed.others, + file, + group, + window.wp && window.wp.oldEditor ? classic : null, // Only add the classic block in WP Context + html, + mediaText, + latestComments, + latestPosts, + missing, + more, + nextpage, + preformatted, + pullquote, + rss, + search, + separator, + reusableBlock, + spacer, + subhead, + table, + tagCloud, + textColumns, + verse, + video, + ].forEach( registerBlock ); + + setDefaultBlockName( paragraph.name ); + if ( window.wp && window.wp.oldEditor ) { + setFreeformContentHandlerName( classic.name ); + } + setUnregisteredTypeHandlerName( missing.name ); + + if ( group ) { + setGroupingBlockName( group.name ); + } +}; + +/** + * Function to register experimental core blocks depending on editor settings. + * + * @param {Object} settings Editor settings. + * + * @example + * ```js + * import { __experimentalRegisterExperimentalCoreBlocks } from '@wordpress/block-library'; + * + * __experimentalRegisterExperimentalCoreBlocks( settings ); + * ``` + */ +export const __experimentalRegisterExperimentalCoreBlocks = process.env.GUTENBERG_PHASE === 2 ? ( settings ) => { + const { __experimentalEnableLegacyWidgetBlock, __experimentalEnableMenuBlock } = settings; + + [ + __experimentalEnableLegacyWidgetBlock ? legacyWidget : null, + __experimentalEnableMenuBlock ? navigationMenu : null, + __experimentalEnableMenuBlock ? navigationMenuItem : null, + socialLinks, + ...socialLink.sites, + ].forEach( registerBlock ); +} : undefined; diff --git a/gb-src/packages/block-library/src/index.native.js b/gb-src/packages/block-library/src/index.native.js new file mode 100644 index 0000000000000..8d5eab4182e50 --- /dev/null +++ b/gb-src/packages/block-library/src/index.native.js @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ +import { + registerBlockType, + setDefaultBlockName, + setUnregisteredTypeHandlerName, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import * as paragraph from './paragraph'; +import * as image from './image'; +import * as heading from './heading'; +import * as quote from './quote'; +import * as gallery from './gallery'; +import * as archives from './archives'; +import * as audio from './audio'; +import * as button from './button'; +import * as calendar from './calendar'; +import * as categories from './categories'; +import * as code from './code'; +import * as columns from './columns'; +import * as column from './column'; +import * as cover from './cover'; +import * as embed from './embed'; +import * as file from './file'; +import * as html from './html'; +import * as mediaText from './media-text'; +import * as latestComments from './latest-comments'; +import * as latestPosts from './latest-posts'; +import * as list from './list'; +import * as missing from './missing'; +import * as more from './more'; +import * as nextpage from './nextpage'; +import * as preformatted from './preformatted'; +import * as pullquote from './pullquote'; +import * as reusableBlock from './block'; +import * as rss from './rss'; +import * as search from './search'; +import * as separator from './separator'; +import * as shortcode from './shortcode'; +import * as spacer from './spacer'; +import * as subhead from './subhead'; +import * as table from './table'; +import * as textColumns from './text-columns'; +import * as verse from './verse'; +import * as video from './video'; +import * as tagCloud from './tag-cloud'; + +export const coreBlocks = [ + // Common blocks are grouped at the top to prioritize their display + // in various contexts — like the inserter and auto-complete components. + paragraph, + image, + heading, + gallery, + list, + quote, + + // Register all remaining core blocks. + shortcode, + archives, + audio, + button, + calendar, + categories, + code, + columns, + column, + cover, + embed, + ...embed.common, + ...embed.others, + file, + html, + mediaText, + latestComments, + latestPosts, + missing, + more, + nextpage, + preformatted, + pullquote, + rss, + search, + separator, + reusableBlock, + spacer, + subhead, + table, + tagCloud, + textColumns, + verse, + video, +].reduce( ( memo, block ) => { + memo[ block.name ] = block; + return memo; +}, {} ); + +export const registerCoreBlocks = () => { + [ + paragraph, + heading, + code, + missing, + more, + image, + video, + nextpage, + separator, + list, + quote, + ].forEach( ( { metadata, name, settings } ) => { + registerBlockType( name, { + ...metadata, + ...settings, + } ); + } ); +}; + +setDefaultBlockName( paragraph.name ); +setUnregisteredTypeHandlerName( missing.name ); diff --git a/gb-src/packages/block-library/src/latest-comments/edit.js b/gb-src/packages/block-library/src/latest-comments/edit.js new file mode 100644 index 0000000000000..029778e24f2c5 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/edit.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { + Disabled, + PanelBody, + RangeControl, + ToggleControl, +} from '@wordpress/components'; +import ServerSideRender from '@wordpress/server-side-render'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Minimum number of comments a user can show using this block. + * + * @type {number} + */ +const MIN_COMMENTS = 1; +/** + * Maximum number of comments a user can show using this block. + * + * @type {number} + */ +const MAX_COMMENTS = 100; + +class LatestComments extends Component { + constructor() { + super( ...arguments ); + + this.setCommentsToShow = this.setCommentsToShow.bind( this ); + + // Create toggles for each attribute; we create them here rather than + // passing `this.createToggleAttribute( 'displayAvatar' )` directly to + // `onChange` to avoid re-renders. + this.toggleDisplayAvatar = this.createToggleAttribute( 'displayAvatar' ); + this.toggleDisplayDate = this.createToggleAttribute( 'displayDate' ); + this.toggleDisplayExcerpt = this.createToggleAttribute( 'displayExcerpt' ); + } + + createToggleAttribute( propName ) { + return () => { + const value = this.props.attributes[ propName ]; + const { setAttributes } = this.props; + + setAttributes( { [ propName ]: ! value } ); + }; + } + + setCommentsToShow( commentsToShow ) { + this.props.setAttributes( { commentsToShow } ); + } + + render() { + const { + commentsToShow, + displayAvatar, + displayDate, + displayExcerpt, + } = this.props.attributes; + + return ( + <> + <InspectorControls> + <PanelBody title={ __( 'Latest Comments Settings' ) }> + <ToggleControl + label={ __( 'Display Avatar' ) } + checked={ displayAvatar } + onChange={ this.toggleDisplayAvatar } + /> + <ToggleControl + label={ __( 'Display Date' ) } + checked={ displayDate } + onChange={ this.toggleDisplayDate } + /> + <ToggleControl + label={ __( 'Display Excerpt' ) } + checked={ displayExcerpt } + onChange={ this.toggleDisplayExcerpt } + /> + <RangeControl + label={ __( 'Number of Comments' ) } + value={ commentsToShow } + onChange={ this.setCommentsToShow } + min={ MIN_COMMENTS } + max={ MAX_COMMENTS } + required + /> + </PanelBody> + </InspectorControls> + <Disabled> + <ServerSideRender + block="core/latest-comments" + attributes={ this.props.attributes } + /> + </Disabled> + </> + ); + } +} + +export default LatestComments; diff --git a/gb-src/packages/block-library/src/latest-comments/editor.scss b/gb-src/packages/block-library/src/latest-comments/editor.scss new file mode 100644 index 0000000000000..e5116c2c5bf4a --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/editor.scss @@ -0,0 +1,14 @@ +.wp-block-latest-comments.has-avatars .avatar { + margin-right: 10px; +} + +.wp-block-latest-comments__comment-excerpt p { + font-size: 14px; + line-height: $editor-line-height; + margin: 5px 0 20px; + padding-top: 0; +} + +.wp-block-latest-comments.has-avatars .wp-block-latest-comments__comment { + min-height: 36px; +} diff --git a/gb-src/packages/block-library/src/latest-comments/icon.js b/gb-src/packages/block-library/src/latest-comments/icon.js new file mode 100644 index 0000000000000..b14cf145c0ed1 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM20 4v13.17L18.83 16H4V4h16zM6 12h12v2H6zm0-3h12v2H6zm0-3h12v2H6z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/latest-comments/index.js b/gb-src/packages/block-library/src/latest-comments/index.js new file mode 100644 index 0000000000000..ba3214750be62 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/latest-comments'; + +export const settings = { + title: __( 'Latest Comments' ), + description: __( 'Display a list of your most recent comments.' ), + icon, + category: 'widgets', + keywords: [ __( 'recent comments' ) ], + supports: { + align: true, + html: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/latest-comments/index.php b/gb-src/packages/block-library/src/latest-comments/index.php new file mode 100644 index 0000000000000..d68e89a152fc4 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/index.php @@ -0,0 +1,196 @@ +<?php +/** + * Server-side rendering of the `core/latest-comments` block. + * + * @package WordPress + */ + +/** + * Get the post title. + * + * The post title is fetched and if it is blank then a default string is + * returned. + * + * Copied from `wp-admin/includes/template.php`, but we can't include that + * file because: + * + * 1. It causes bugs with test fixture generation and strange Docker 255 error + * codes. + * 2. It's in the admin; ideally we *shouldn't* be including files from the + * admin for a block's output. It's a very small/simple function as well, + * so duplicating it isn't too terrible. + * + * @since 3.3.0 + * + * @param int|WP_Post $post Optional. Post ID or WP_Post object. Default is global $post. + * @return string The post title if set; "(no title)" if no title is set. + */ +function wp_latest_comments_draft_or_post_title( $post = 0 ) { + $title = get_the_title( $post ); + if ( empty( $title ) ) { + $title = __( '(no title)' ); + } + return esc_html( $title ); +} + +/** + * Renders the `core/latest-comments` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the post content with latest comments added. + */ +function render_block_core_latest_comments( $attributes = array() ) { + // This filter is documented in wp-includes/widgets/class-wp-widget-recent-comments.php. + $comments = get_comments( + apply_filters( + 'widget_comments_args', + array( + 'number' => $attributes['commentsToShow'], + 'status' => 'approve', + 'post_status' => 'publish', + ) + ) + ); + + $list_items_markup = ''; + if ( ! empty( $comments ) ) { + // Prime the cache for associated posts. This is copied from \WP_Widget_Recent_Comments::widget(). + $post_ids = array_unique( wp_list_pluck( $comments, 'comment_post_ID' ) ); + _prime_post_caches( $post_ids, strpos( get_option( 'permalink_structure' ), '%category%' ), false ); + + foreach ( $comments as $comment ) { + $list_items_markup .= '<li class="wp-block-latest-comments__comment">'; + if ( $attributes['displayAvatar'] ) { + $avatar = get_avatar( + $comment, + 48, + '', + '', + array( + 'class' => 'wp-block-latest-comments__comment-avatar', + ) + ); + if ( $avatar ) { + $list_items_markup .= $avatar; + } + } + + $list_items_markup .= '<article>'; + $list_items_markup .= '<footer class="wp-block-latest-comments__comment-meta">'; + $author_url = get_comment_author_url( $comment ); + if ( empty( $author_url ) && ! empty( $comment->user_id ) ) { + $author_url = get_author_posts_url( $comment->user_id ); + } + + $author_markup = ''; + if ( $author_url ) { + $author_markup .= '<a class="wp-block-latest-comments__comment-author" href="' . esc_url( $author_url ) . '">' . get_comment_author( $comment ) . '</a>'; + } else { + $author_markup .= '<span class="wp-block-latest-comments__comment-author">' . get_comment_author( $comment ) . '</span>'; + } + + // `_draft_or_post_title` calls `esc_html()` so we don't need to wrap that call in + // `esc_html`. + $post_title = '<a class="wp-block-latest-comments__comment-link" href="' . esc_url( get_comment_link( $comment ) ) . '">' . wp_latest_comments_draft_or_post_title( $comment->comment_post_ID ) . '</a>'; + + $list_items_markup .= sprintf( + /* translators: 1: author name (inside <a> or <span> tag, based on if they have a URL), 2: post title related to this comment */ + __( '%1$s on %2$s' ), + $author_markup, + $post_title + ); + + if ( $attributes['displayDate'] ) { + $list_items_markup .= sprintf( + '<time datetime="%1$s" class="wp-block-latest-comments__comment-date">%2$s</time>', + esc_attr( get_comment_date( 'c', $comment ) ), + date_i18n( get_option( 'date_format' ), get_comment_date( 'U', $comment ) ) + ); + } + $list_items_markup .= '</footer>'; + if ( $attributes['displayExcerpt'] ) { + $list_items_markup .= '<div class="wp-block-latest-comments__comment-excerpt">' . wpautop( get_comment_excerpt( $comment ) ) . '</div>'; + } + $list_items_markup .= '</article></li>'; + } + } + + $class = 'wp-block-latest-comments'; + if ( ! empty( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + if ( isset( $attributes['align'] ) ) { + $class .= " align{$attributes['align']}"; + } + if ( $attributes['displayAvatar'] ) { + $class .= ' has-avatars'; + } + if ( $attributes['displayDate'] ) { + $class .= ' has-dates'; + } + if ( $attributes['displayExcerpt'] ) { + $class .= ' has-excerpts'; + } + if ( empty( $comments ) ) { + $class .= ' no-comments'; + } + $classnames = esc_attr( $class ); + + return ! empty( $comments ) ? sprintf( + '<ol class="%1$s">%2$s</ol>', + $classnames, + $list_items_markup + ) : sprintf( + '<div class="%1$s">%2$s</div>', + $classnames, + __( 'No comments to show.' ) + ); +} + +/** + * Registers the `core/latest-comments` block. + */ +function register_block_core_latest_comments() { + register_block_type( + 'core/latest-comments', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( + 'left', + 'center', + 'right', + 'wide', + 'full', + ), + ), + 'className' => array( + 'type' => 'string', + ), + 'commentsToShow' => array( + 'type' => 'number', + 'default' => 5, + 'minimum' => 1, + 'maximum' => 100, + ), + 'displayAvatar' => array( + 'type' => 'boolean', + 'default' => true, + ), + 'displayDate' => array( + 'type' => 'boolean', + 'default' => true, + ), + 'displayExcerpt' => array( + 'type' => 'boolean', + 'default' => true, + ), + ), + 'render_callback' => 'render_block_core_latest_comments', + ) + ); +} + +add_action( 'init', 'register_block_core_latest_comments' ); diff --git a/gb-src/packages/block-library/src/latest-comments/style.scss b/gb-src/packages/block-library/src/latest-comments/style.scss new file mode 100644 index 0000000000000..81ebe5dbf5937 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-comments/style.scss @@ -0,0 +1,43 @@ +.wp-block-latest-comments__comment { + font-size: 15px; + line-height: 1.1; + list-style: none; + margin-bottom: 1em; + + .has-avatars & { + min-height: 36px; + list-style: none; + + .wp-block-latest-comments__comment-meta, + .wp-block-latest-comments__comment-excerpt { + margin-left: 52px; + } + } + + .has-dates &, + .has-excerpts & { + line-height: 1.5; + } +} + +.wp-block-latest-comments__comment-excerpt p { + font-size: 14px; + line-height: 1.8; + margin: 5px 0 20px; +} + +.wp-block-latest-comments__comment-date { + color: $dark-gray-100; + display: block; + font-size: 12px; +} + +.wp-block-latest-comments .avatar, +.wp-block-latest-comments__comment-avatar { + border-radius: 24px; + display: block; + float: left; + height: 40px; + margin-right: 12px; + width: 40px; +} diff --git a/gb-src/packages/block-library/src/latest-posts/edit.js b/gb-src/packages/block-library/src/latest-posts/edit.js new file mode 100644 index 0000000000000..6d0e4198e8242 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/edit.js @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import { isUndefined, pickBy } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component, RawHTML } from '@wordpress/element'; +import { + PanelBody, + Placeholder, + QueryControls, + RangeControl, + Spinner, + ToggleControl, + Toolbar, + RadioControl, +} from '@wordpress/components'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { __ } from '@wordpress/i18n'; +import { dateI18n, format, __experimentalGetSettings } from '@wordpress/date'; +import { + InspectorControls, + BlockControls, +} from '@wordpress/block-editor'; +import { withSelect } from '@wordpress/data'; + +/** + * Module Constants + */ +const CATEGORIES_LIST_QUERY = { + per_page: -1, +}; +const MAX_POSTS_COLUMNS = 6; + +class LatestPostsEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + categoriesList: [], + }; + } + + componentDidMount() { + this.isStillMounted = true; + this.fetchRequest = apiFetch( { + path: addQueryArgs( `/wp/v2/categories`, CATEGORIES_LIST_QUERY ), + } ).then( + ( categoriesList ) => { + if ( this.isStillMounted ) { + this.setState( { categoriesList } ); + } + } + ).catch( + () => { + if ( this.isStillMounted ) { + this.setState( { categoriesList: [] } ); + } + } + ); + } + + componentWillUnmount() { + this.isStillMounted = false; + } + + render() { + const { attributes, setAttributes, latestPosts } = this.props; + const { categoriesList } = this.state; + const { displayPostContentRadio, displayPostContent, displayPostDate, postLayout, columns, order, orderBy, categories, postsToShow, excerptLength } = attributes; + + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ __( 'Post Content Settings' ) }> + <ToggleControl + label={ __( 'Post Content' ) } + checked={ displayPostContent } + onChange={ ( value ) => setAttributes( { displayPostContent: value } ) } + /> + { displayPostContent && + <RadioControl + label="Show:" + selected={ displayPostContentRadio } + options={ [ + { label: 'Excerpt', value: 'excerpt' }, + { label: 'Full Post', value: 'full_post' }, + ] } + onChange={ ( value ) => setAttributes( { displayPostContentRadio: value } ) } + /> + } + { displayPostContent && displayPostContentRadio === 'excerpt' && + <RangeControl + label={ __( 'Max number of words in excerpt' ) } + value={ excerptLength } + onChange={ ( value ) => setAttributes( { excerptLength: value } ) } + min={ 10 } + max={ 100 } + /> + } + </PanelBody> + + <PanelBody title={ __( 'Post Meta Settings' ) }> + <ToggleControl + label={ __( 'Display post date' ) } + checked={ displayPostDate } + onChange={ ( value ) => setAttributes( { displayPostDate: value } ) } + /> + </PanelBody> + + <PanelBody title={ __( 'Sorting and Filtering' ) }> + <QueryControls + { ...{ order, orderBy } } + numberOfItems={ postsToShow } + categoriesList={ categoriesList } + selectedCategoryId={ categories } + onOrderChange={ ( value ) => setAttributes( { order: value } ) } + onOrderByChange={ ( value ) => setAttributes( { orderBy: value } ) } + onCategoryChange={ ( value ) => setAttributes( { categories: '' !== value ? value : undefined } ) } + onNumberOfItemsChange={ ( value ) => setAttributes( { postsToShow: value } ) } + /> + { postLayout === 'grid' && + <RangeControl + label={ __( 'Columns' ) } + value={ columns } + onChange={ ( value ) => setAttributes( { columns: value } ) } + min={ 2 } + max={ ! hasPosts ? MAX_POSTS_COLUMNS : Math.min( MAX_POSTS_COLUMNS, latestPosts.length ) } + required + /> + } + </PanelBody> + </InspectorControls> + ); + + const hasPosts = Array.isArray( latestPosts ) && latestPosts.length; + if ( ! hasPosts ) { + return ( + <> + { inspectorControls } + <Placeholder + icon="admin-post" + label={ __( 'Latest Posts' ) } + > + { ! Array.isArray( latestPosts ) ? + <Spinner /> : + __( 'No posts found.' ) + } + </Placeholder> + </> + ); + } + + // Removing posts from display should be instant. + const displayPosts = latestPosts.length > postsToShow ? + latestPosts.slice( 0, postsToShow ) : + latestPosts; + + const layoutControls = [ + { + icon: 'list-view', + title: __( 'List view' ), + onClick: () => setAttributes( { postLayout: 'list' } ), + isActive: postLayout === 'list', + }, + { + icon: 'grid-view', + title: __( 'Grid view' ), + onClick: () => setAttributes( { postLayout: 'grid' } ), + isActive: postLayout === 'grid', + }, + ]; + + const dateFormat = __experimentalGetSettings().formats.date; + + return ( + <> + { inspectorControls } + <BlockControls> + <Toolbar controls={ layoutControls } /> + </BlockControls> + <ul + className={ classnames( this.props.className, { + 'wp-block-latest-posts__list': true, + 'is-grid': postLayout === 'grid', + 'has-dates': displayPostDate, + [ `columns-${ columns }` ]: postLayout === 'grid', + } ) } + > + { displayPosts.map( ( post, i ) => { + const titleTrimmed = post.title.rendered.trim(); + let excerpt = post.excerpt.rendered; + if ( post.excerpt.raw === '' ) { + excerpt = post.content.raw; + } + const excerptElement = document.createElement( 'div' ); + excerptElement.innerHTML = excerpt; + excerpt = excerptElement.textContent || excerptElement.innerText || ''; + return ( + <li key={ i }> + <a href={ post.link } target="_blank" rel="noreferrer noopener"> + { titleTrimmed ? ( + <RawHTML> + { titleTrimmed } + </RawHTML> + ) : + __( '(no title)' ) + } + </a> + { displayPostDate && post.date_gmt && + <time dateTime={ format( 'c', post.date_gmt ) } className="wp-block-latest-posts__post-date"> + { dateI18n( dateFormat, post.date_gmt ) } + </time> + } + { displayPostContent && displayPostContentRadio === 'excerpt' && + <div className="wp-block-latest-posts__post-excerpt"> + <RawHTML + key="html" + > + { excerptLength < excerpt.trim().split( ' ' ).length ? + excerpt.trim().split( ' ', excerptLength ).join( ' ' ) + ' ... <a href=' + post.link + 'target="_blank" rel="noopener noreferrer">' + __( 'Read more' ) + '</a>' : + excerpt.trim().split( ' ', excerptLength ).join( ' ' ) } + </RawHTML> + </div> + } + { displayPostContent && displayPostContentRadio === 'full_post' && + <div className="wp-block-latest-posts__post-full-content"> + <RawHTML + key="html" + > + { post.content.raw.trim() } + </RawHTML> + </div> + } + </li> + ); + } ) } + </ul> + </> + ); + } +} + +export default withSelect( ( select, props ) => { + const { postsToShow, order, orderBy, categories } = props.attributes; + const { getEntityRecords } = select( 'core' ); + const latestPostsQuery = pickBy( { + categories, + order, + orderby: orderBy, + per_page: postsToShow, + }, ( value ) => ! isUndefined( value ) ); + return { + latestPosts: getEntityRecords( 'postType', 'post', latestPostsQuery ), + }; +} )( LatestPostsEdit ); diff --git a/gb-src/packages/block-library/src/latest-posts/editor.scss b/gb-src/packages/block-library/src/latest-posts/editor.scss new file mode 100644 index 0000000000000..a8a538967a3e8 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/editor.scss @@ -0,0 +1,10 @@ +.block-editor .wp-block-latest-posts { + padding-left: 2.5em; + &.is-grid { + padding-left: 0; + } +} + +.wp-block-latest-posts li a > div { + display: inline; +} diff --git a/gb-src/packages/block-library/src/latest-posts/icon.js b/gb-src/packages/block-library/src/latest-posts/icon.js new file mode 100644 index 0000000000000..7233084d330bc --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, Rect, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Rect x="11" y="7" width="6" height="2" /><Rect x="11" y="11" width="6" height="2" /><Rect x="11" y="15" width="6" height="2" /><Rect x="7" y="7" width="2" height="2" /><Rect x="7" y="11" width="2" height="2" /><Rect x="7" y="15" width="2" height="2" /><Path d="M20.1,3H3.9C3.4,3,3,3.4,3,3.9v16.2C3,20.5,3.4,21,3.9,21h16.2c0.4,0,0.9-0.5,0.9-0.9V3.9C21,3.4,20.5,3,20.1,3z M19,19H5V5h14V19z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/latest-posts/index.js b/gb-src/packages/block-library/src/latest-posts/index.js new file mode 100644 index 0000000000000..a8cdf38168036 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/latest-posts'; + +export const settings = { + title: __( 'Latest Posts' ), + description: __( 'Display a list of your most recent posts.' ), + icon, + category: 'widgets', + keywords: [ __( 'recent posts' ) ], + supports: { + align: true, + html: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/latest-posts/index.php b/gb-src/packages/block-library/src/latest-posts/index.php new file mode 100644 index 0000000000000..f1b3c78b5cfb8 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/index.php @@ -0,0 +1,189 @@ +<?php +/** + * Server-side rendering of the `core/latest-posts` block. + * + * @package WordPress + */ + +/** + * Renders the `core/latest-posts` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the post content with latest posts added. + */ +function render_block_core_latest_posts( $attributes ) { + $args = array( + 'posts_per_page' => $attributes['postsToShow'], + 'post_status' => 'publish', + 'order' => $attributes['order'], + 'orderby' => $attributes['orderBy'], + 'suppress_filters' => false, + ); + + if ( isset( $attributes['categories'] ) ) { + $args['category'] = $attributes['categories']; + } + + $recent_posts = get_posts( $args ); + + $list_items_markup = ''; + + $excerpt_length = $attributes['excerptLength']; + + foreach ( $recent_posts as $post ) { + $title = get_the_title( $post ); + if ( ! $title ) { + $title = __( '(no title)' ); + } + $list_items_markup .= sprintf( + '<li><a href="%1$s">%2$s</a>', + esc_url( get_permalink( $post ) ), + $title + ); + + if ( isset( $attributes['displayPostDate'] ) && $attributes['displayPostDate'] ) { + $list_items_markup .= sprintf( + '<time datetime="%1$s" class="wp-block-latest-posts__post-date">%2$s</time>', + esc_attr( get_the_date( 'c', $post ) ), + esc_html( get_the_date( '', $post ) ) + ); + } + + if ( isset( $attributes['displayPostContent'] ) && $attributes['displayPostContent'] + && isset( $attributes['displayPostContentRadio'] ) && 'excerpt' === $attributes['displayPostContentRadio'] ) { + $post_excerpt = $post->post_excerpt; + + if ( ! ( $post_excerpt ) ) { + $post_excerpt = $post->post_content; + } + $trimmed_excerpt = esc_html( wp_trim_words( $post_excerpt, $excerpt_length, ' … ' ) ); + + if ( post_password_required( $post ) ) { + $trimmed_excerpt = __( 'This content is password protected.' ); + } + + $list_items_markup .= sprintf( + '<div class="wp-block-latest-posts__post-excerpt">%1$s', + $trimmed_excerpt + ); + + if ( strpos( $trimmed_excerpt, ' … ' ) !== false ) { + $list_items_markup .= sprintf( + '<a href="%1$s">%2$s</a></div>', + esc_url( get_permalink( $post ) ), + __( 'Read more' ) + ); + } else { + $list_items_markup .= sprintf( + '</div>' + ); + } + } + + if ( isset( $attributes['displayPostContent'] ) && $attributes['displayPostContent'] + && isset( $attributes['displayPostContentRadio'] ) && 'full_post' === $attributes['displayPostContentRadio'] ) { + + $post_content = wp_kses_post( html_entity_decode( $post->post_content, ENT_QUOTES, get_option( 'blog_charset' ) ) ); + + if ( post_password_required( $post ) ) { + $post_content = __( 'This content is password protected.' ); + } + + $list_items_markup .= sprintf( + '<div class="wp-block-latest-posts__post-full-content">%1$s</div>', + $post_content + ); + } + + $list_items_markup .= "</li>\n"; + } + + $class = 'wp-block-latest-posts wp-block-latest-posts__list'; + if ( isset( $attributes['align'] ) ) { + $class .= ' align' . $attributes['align']; + } + + if ( isset( $attributes['postLayout'] ) && 'grid' === $attributes['postLayout'] ) { + $class .= ' is-grid'; + } + + if ( isset( $attributes['columns'] ) && 'grid' === $attributes['postLayout'] ) { + $class .= ' columns-' . $attributes['columns']; + } + + if ( isset( $attributes['displayPostDate'] ) && $attributes['displayPostDate'] ) { + $class .= ' has-dates'; + } + + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + return sprintf( + '<ul class="%1$s">%2$s</ul>', + esc_attr( $class ), + $list_items_markup + ); +} + +/** + * Registers the `core/latest-posts` block on server. + */ +function register_block_core_latest_posts() { + register_block_type( + 'core/latest-posts', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'categories' => array( + 'type' => 'string', + ), + 'postsToShow' => array( + 'type' => 'number', + 'default' => 5, + ), + 'displayPostContent' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'displayPostContentRadio' => array( + 'type' => 'string', + 'default' => 'excerpt', + ), + 'excerptLength' => array( + 'type' => 'number', + 'default' => 55, + ), + 'displayPostDate' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'postLayout' => array( + 'type' => 'string', + 'default' => 'list', + ), + 'columns' => array( + 'type' => 'number', + 'default' => 3, + ), + 'order' => array( + 'type' => 'string', + 'default' => 'desc', + ), + 'orderBy' => array( + 'type' => 'string', + 'default' => 'date', + ), + ), + 'render_callback' => 'render_block_core_latest_posts', + ) + ); +} +add_action( 'init', 'register_block_core_latest_posts' ); diff --git a/gb-src/packages/block-library/src/latest-posts/style.scss b/gb-src/packages/block-library/src/latest-posts/style.scss new file mode 100644 index 0000000000000..9bfbbad503e96 --- /dev/null +++ b/gb-src/packages/block-library/src/latest-posts/style.scss @@ -0,0 +1,42 @@ +.wp-block-latest-posts { + &.alignleft { + /*rtl:ignore*/ + margin-right: 2em; + } + &.alignright { + /*rtl:ignore*/ + margin-left: 2em; + } + &.wp-block-latest-posts__list { + list-style: none; + } + &.is-grid { + display: flex; + flex-wrap: wrap; + padding: 0; + + li { + margin: 0 16px 16px 0; + width: 100%; + } + } + + @include break-small { + @for $i from 2 through 6 { + &.columns-#{ $i } li { + width: calc((100% / #{ $i }) - 16px); + } + } + } +} + +.wp-block-latest-posts__post-date { + display: block; + color: $dark-gray-300; + font-size: $default-font-size; +} + +.wp-block-latest-posts__post-excerpt { + margin-top: $grid-size; + margin-bottom: $grid-size-large; +} diff --git a/gb-src/packages/block-library/src/legacy-widget/edit/dom-manager.js b/gb-src/packages/block-library/src/legacy-widget/edit/dom-manager.js new file mode 100644 index 0000000000000..d4a0b43829f29 --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/edit/dom-manager.js @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +class LegacyWidgetEditDomManager extends Component { + constructor() { + super( ...arguments ); + + this.containerRef = createRef(); + this.formRef = createRef(); + this.widgetContentRef = createRef(); + this.triggerWidgetEvent = this.triggerWidgetEvent.bind( this ); + } + + componentDidMount() { + this.triggerWidgetEvent( 'widget-added' ); + this.previousFormData = new window.FormData( + this.formRef.current + ); + } + + shouldComponentUpdate( nextProps ) { + // We can not leverage react render otherwise we would destroy dom changes applied by the plugins. + // We manually update the required dom node replicating what the widget screen and the customizer do. + if ( nextProps.form !== this.props.form && this.widgetContentRef.current ) { + const widgetContent = this.widgetContentRef.current; + widgetContent.innerHTML = nextProps.form; + this.triggerWidgetEvent( 'widget-updated' ); + this.previousFormData = new window.FormData( + this.formRef.current + ); + } + return false; + } + + render() { + const { id, idBase, widgetNumber, form } = this.props; + return ( + <div className="widget open" ref={ this.containerRef }> + <div className="widget-inside"> + <form + ref={ this.formRef } + method="post" + onBlur={ () => { + if ( this.shouldTriggerInstanceUpdate() ) { + this.props.onInstanceChange( + this.retrieveUpdatedInstance() + ); + } + } } + > + <div + ref={ this.widgetContentRef } + className="widget-content" + dangerouslySetInnerHTML={ { __html: form } } + /> + <input type="hidden" name="widget-id" className="widget-id" value={ id } /> + <input type="hidden" name="id_base" className="id_base" value={ idBase } /> + <input type="hidden" name="widget_number" className="widget_number" value={ widgetNumber } /> + <input type="hidden" name="multi_number" className="multi_number" value="" /> + <input type="hidden" name="add_new" className="add_new" value="" /> + </form> + </div> + </div> + ); + } + + shouldTriggerInstanceUpdate() { + if ( ! this.formRef.current ) { + return false; + } + if ( ! this.previousFormData ) { + return true; + } + const currentFormData = new window.FormData( + this.formRef.current + ); + const currentFormDataKeys = Array.from( currentFormData.keys() ); + const previousFormDataKeys = Array.from( this.previousFormData.keys() ); + if ( + currentFormDataKeys.length !== previousFormDataKeys.length + ) { + return true; + } + for ( const rawKey of currentFormDataKeys ) { + if ( ! isShallowEqual( + currentFormData.getAll( rawKey ), + this.previousFormData.getAll( rawKey ) + ) ) { + this.previousFormData = currentFormData; + return true; + } + } + return false; + } + + triggerWidgetEvent( event ) { + window.$( window.document ).trigger( + event, + [ window.$( this.containerRef.current ) ] + ); + } + + retrieveUpdatedInstance() { + if ( this.formRef.current ) { + const { idBase, widgetNumber } = this.props; + const form = this.formRef.current; + const formData = new window.FormData( form ); + const updatedInstance = {}; + const keyPrefixLength = `widget-${ idBase }[${ widgetNumber }][`.length; + const keySuffixLength = `]`.length; + for ( const rawKey of formData.keys() ) { + // This fields are added to the form because the widget JavaScript code may use this values. + // They are not relevant for the update mechanism. + if ( includes( + [ 'widget-id', 'id_base', 'widget_number', 'multi_number', 'add_new' ], + rawKey, + ) ) { + continue; + } + const keyParsed = rawKey.substring( keyPrefixLength, rawKey.length - keySuffixLength ); + + const value = formData.getAll( rawKey ); + if ( value.length > 1 ) { + updatedInstance[ keyParsed ] = value; + } else { + updatedInstance[ keyParsed ] = value[ 0 ]; + } + } + return updatedInstance; + } + } +} + +export default LegacyWidgetEditDomManager; + diff --git a/gb-src/packages/block-library/src/legacy-widget/edit/handler.js b/gb-src/packages/block-library/src/legacy-widget/edit/handler.js new file mode 100644 index 0000000000000..157ab653fb89f --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/edit/handler.js @@ -0,0 +1,122 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import LegacyWidgetEditDomManager from './dom-manager'; + +class LegacyWidgetEditHandler extends Component { + constructor() { + super( ...arguments ); + this.state = { + form: null, + idBase: null, + }; + this.instanceUpdating = null; + this.onInstanceChange = this.onInstanceChange.bind( this ); + this.requestWidgetUpdater = this.requestWidgetUpdater.bind( this ); + } + + componentDidMount() { + this.isStillMounted = true; + this.requestWidgetUpdater(); + } + + componentDidUpdate( prevProps ) { + if ( + prevProps.instance !== this.props.instance && + this.instanceUpdating !== this.props.instance + ) { + this.requestWidgetUpdater(); + } + if ( this.instanceUpdating === this.props.instance ) { + this.instanceUpdating = null; + } + } + + componentWillUnmount() { + this.isStillMounted = false; + } + + render() { + const { instanceId, identifier } = this.props; + const { id, idBase, form } = this.state; + if ( ! identifier ) { + return __( 'Not a valid widget.' ); + } + if ( ! form ) { + return null; + } + return ( + <div + className="wp-block-legacy-widget__edit-container" + // Display none is used because when we switch from edit to preview, + // we don't want to unmount the component. + // Otherwise when we went back to edit we wound need to trigger + // all widgets events again and some scripts may not deal well with this. + style={ { + display: this.props.isVisible ? 'block' : 'none', + } } + > + <LegacyWidgetEditDomManager + ref={ ( ref ) => { + this.widgetEditDomManagerRef = ref; + } } + onInstanceChange={ this.onInstanceChange } + widgetNumber={ instanceId * -1 } + id={ id } + idBase={ idBase } + form={ form } + /> + </div> + ); + } + + onInstanceChange( instanceChanges ) { + this.requestWidgetUpdater( instanceChanges, ( response ) => { + this.instanceUpdating = response.instance; + this.props.onInstanceChange( response.instance ); + } ); + } + + requestWidgetUpdater( instanceChanges, callback ) { + const { identifier, instanceId, instance } = this.props; + if ( ! identifier ) { + return; + } + + apiFetch( { + path: `/wp/v2/widgets/${ identifier }/`, + data: { + identifier, + instance, + // use negative ids to make sure the id does not exist on the database. + id_to_use: instanceId * -1, + instance_changes: instanceChanges, + }, + method: 'POST', + } ).then( + ( response ) => { + if ( this.isStillMounted ) { + this.setState( { + form: response.form, + idBase: response.id_base, + id: response.id, + } ); + if ( callback ) { + callback( response ); + } + } + } + ); + } +} + +export default withInstanceId( LegacyWidgetEditHandler ); + diff --git a/gb-src/packages/block-library/src/legacy-widget/edit/index.js b/gb-src/packages/block-library/src/legacy-widget/edit/index.js new file mode 100644 index 0000000000000..31f36c2f7fac9 --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/edit/index.js @@ -0,0 +1,164 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + Button, + IconButton, + PanelBody, + Toolbar, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withSelect } from '@wordpress/data'; +import { + BlockControls, + InspectorControls, +} from '@wordpress/block-editor'; +import ServerSideRender from '@wordpress/server-side-render'; + +/** + * Internal dependencies + */ +import LegacyWidgetEditHandler from './handler'; +import LegacyWidgetPlaceholder from './placeholder'; + +class LegacyWidgetEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + isPreview: false, + }; + this.switchToEdit = this.switchToEdit.bind( this ); + this.switchToPreview = this.switchToPreview.bind( this ); + this.changeWidget = this.changeWidget.bind( this ); + } + + render() { + const { + attributes, + availableLegacyWidgets, + hasPermissionsToManageWidgets, + setAttributes, + } = this.props; + const { isPreview } = this.state; + const { identifier, isCallbackWidget } = attributes; + const widgetObject = identifier && availableLegacyWidgets[ identifier ]; + if ( ! widgetObject ) { + return ( + <LegacyWidgetPlaceholder + availableLegacyWidgets={ availableLegacyWidgets } + currentWidget={ identifier } + hasPermissionsToManageWidgets={ hasPermissionsToManageWidgets } + onChangeWidget={ ( newWidget ) => setAttributes( { + instance: {}, + identifier: newWidget, + isCallbackWidget: availableLegacyWidgets[ newWidget ].isCallbackWidget, + } ) } + /> + ); + } + + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ widgetObject.name }> + { widgetObject.description } + </PanelBody> + </InspectorControls> + ); + if ( ! hasPermissionsToManageWidgets ) { + return ( + <> + { inspectorControls } + { this.renderWidgetPreview() } + </> + ); + } + + return ( + <> + <BlockControls> + <Toolbar> + { ! widgetObject.isHidden && ( + <IconButton + onClick={ this.changeWidget } + label={ __( 'Change widget' ) } + icon="update" + /> + ) } + { ! isCallbackWidget && ( + <> + <Button + className={ `components-tab-button ${ ! isPreview ? 'is-active' : '' }` } + onClick={ this.switchToEdit } + > + <span>{ __( 'Edit' ) }</span> + </Button> + <Button + className={ `components-tab-button ${ isPreview ? 'is-active' : '' }` } + onClick={ this.switchToPreview } + > + <span>{ __( 'Preview' ) }</span> + </Button> + </> + ) } + </Toolbar> + </BlockControls> + { inspectorControls } + { ! isCallbackWidget && ( + <LegacyWidgetEditHandler + isVisible={ ! isPreview } + identifier={ attributes.identifier } + instance={ attributes.instance } + onInstanceChange={ + ( newInstance ) => { + this.props.setAttributes( { + instance: newInstance, + } ); + } + } + /> + ) } + { ( isPreview || isCallbackWidget ) && this.renderWidgetPreview() } + </> + ); + } + + changeWidget() { + this.switchToEdit(); + this.props.setAttributes( { + instance: {}, + identifier: undefined, + } ); + } + + switchToEdit() { + this.setState( { isPreview: false } ); + } + + switchToPreview() { + this.setState( { isPreview: true } ); + } + + renderWidgetPreview() { + const { attributes } = this.props; + return ( + <ServerSideRender + className="wp-block-legacy-widget__preview" + block="core/legacy-widget" + attributes={ attributes } + /> + ); + } +} + +export default withSelect( ( select ) => { + const editorSettings = select( 'core/block-editor' ).getSettings(); + const { + availableLegacyWidgets, + hasPermissionsToManageWidgets, + } = editorSettings; + return { + hasPermissionsToManageWidgets, + availableLegacyWidgets, + }; +} )( LegacyWidgetEdit ); diff --git a/gb-src/packages/block-library/src/legacy-widget/edit/placeholder.js b/gb-src/packages/block-library/src/legacy-widget/edit/placeholder.js new file mode 100644 index 0000000000000..b19f5654ab4c6 --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/edit/placeholder.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { pickBy, isEmpty, map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { SelectControl, Placeholder } from '@wordpress/components'; +import { BlockIcon } from '@wordpress/block-editor'; + +export default function LegacyWidgetPlaceholder( { + availableLegacyWidgets, + currentWidget, + hasPermissionsToManageWidgets, + onChangeWidget, +} ) { + const visibleLegacyWidgets = useMemo( + () => pickBy( + availableLegacyWidgets, + ( { isHidden } ) => ! isHidden + ), + [ availableLegacyWidgets ] + ); + let placeholderContent; + if ( ! hasPermissionsToManageWidgets ) { + placeholderContent = __( 'You don\'t have permissions to use widgets on this site.' ); + } + if ( isEmpty( visibleLegacyWidgets ) ) { + placeholderContent = __( 'There are no widgets available.' ); + } + placeholderContent = ( + <SelectControl + label={ __( 'Select a legacy widget to display:' ) } + value={ currentWidget || 'none' } + onChange={ onChangeWidget } + options={ [ { value: 'none', label: 'Select widget' } ].concat( + map( visibleLegacyWidgets, ( widget, key ) => { + return { + value: key, + label: widget.name, + }; + } ) + ) } + /> + ); + return ( + <Placeholder + icon={ <BlockIcon icon="admin-customizer" /> } + label={ __( 'Legacy Widget' ) } + > + { placeholderContent } + </Placeholder> + ); +} diff --git a/gb-src/packages/block-library/src/legacy-widget/editor.scss b/gb-src/packages/block-library/src/legacy-widget/editor.scss new file mode 100644 index 0000000000000..29f9c0b869731 --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/editor.scss @@ -0,0 +1,22 @@ +.wp-block-legacy-widget__edit-container, +.wp-block-legacy-widget__preview { + padding-left: 2.5em; + padding-right: 2.5em; +} + +.wp-block-legacy-widget__edit-container { + .widget-inside { + border: none; + display: block; + box-shadow: none; + } +} + +.wp-block-legacy-widget__update-button { + margin-left: auto; + display: block; +} + +.wp-block-legacy-widget__preview { + overflow: auto; +} diff --git a/gb-src/packages/block-library/src/legacy-widget/icon.js b/gb-src/packages/block-library/src/legacy-widget/icon.js new file mode 100644 index 0000000000000..31731fc0530d3 --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M7 11h2v2H7v-2zm14-5v14l-2 2H5l-2-2V6l2-2h1V2h2v2h8V2h2v2h1l2 2zM5 8h14V6H5v2zm14 12V10H5v10h14zm-4-7h2v-2h-2v2zm-4 0h2v-2h-2v2z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/legacy-widget/index.js b/gb-src/packages/block-library/src/legacy-widget/index.js new file mode 100644 index 0000000000000..67d52f8a7cedb --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; + +export const name = 'core/legacy-widget'; + +export const settings = { + title: __( 'Legacy Widget (Experimental)' ), + description: __( 'Display a legacy widget.' ), + icon, + category: 'widgets', + supports: { + html: false, + customClassName: false, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/legacy-widget/index.php b/gb-src/packages/block-library/src/legacy-widget/index.php new file mode 100644 index 0000000000000..b812f8e3c710f --- /dev/null +++ b/gb-src/packages/block-library/src/legacy-widget/index.php @@ -0,0 +1,78 @@ +<?php +/** + * Server-side rendering of the `core/legacy-widget` block. + * + * @package WordPress + */ + +/** + * Renders the `core/legacy-widget` block on server. + * + * @see WP_Widget + * + * @param array $attributes The block attributes. + * + * @return string Returns the post content with the legacy widget added. + */ +function render_block_legacy_widget( $attributes ) { + if ( ! isset( $attributes['identifier'] ) ) { + return ''; + } + $identifier = $attributes['identifier']; + if ( + isset( $attributes['isCallbackWidget'] ) && + $attributes['isCallbackWidget'] + ) { + global $wp_registered_widgets; + if ( ! isset( $wp_registered_widgets[ $identifier ] ) ) { + return ''; + } + $widget = $wp_registered_widgets[ $identifier ]; + $params = array_merge( + array( + 'widget_id' => $identifier, + 'widget_name' => $widget['name'], + ), + (array) $wp_registered_widgets[ $identifier ]['params'] + ); + $params = apply_filters( 'dynamic_sidebar_params', $params ); + + $callback = $widget['callback']; + + if ( is_callable( $callback ) ) { + ob_start(); + call_user_func_array( $callback, $params ); + return ob_get_clean(); + } + return ''; + } + ob_start(); + the_widget( $identifier, $attributes['instance'] ); + return ob_get_clean(); + +} + +/** + * Register legacy widget block. + */ +function register_block_core_legacy_widget() { + register_block_type( + 'core/legacy-widget', + array( + 'attributes' => array( + 'identifier' => array( + 'type' => 'string', + ), + 'instance' => array( + 'type' => 'object', + ), + 'isCallbackWidget' => array( + 'type' => 'boolean', + ), + ), + 'render_callback' => 'render_block_legacy_widget', + ) + ); +} + +add_action( 'init', 'register_block_core_legacy_widget' ); diff --git a/gb-src/packages/block-library/src/list/block.json b/gb-src/packages/block-library/src/list/block.json new file mode 100644 index 0000000000000..3dcae3a6ae5c2 --- /dev/null +++ b/gb-src/packages/block-library/src/list/block.json @@ -0,0 +1,24 @@ +{ + "name": "core/list", + "category": "common", + "attributes": { + "ordered": { + "type": "boolean", + "default": false + }, + "values": { + "type": "string", + "source": "html", + "selector": "ol,ul", + "multiline": "li", + "__unstableMultilineWrapperTags": [ "ol", "ul" ], + "default": "" + }, + "start": { + "type": "number" + }, + "reversed": { + "type": "boolean" + } + } +} diff --git a/gb-src/packages/block-library/src/list/edit.js b/gb-src/packages/block-library/src/list/edit.js new file mode 100644 index 0000000000000..2877a83634531 --- /dev/null +++ b/gb-src/packages/block-library/src/list/edit.js @@ -0,0 +1,145 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; +import { + RichText, + BlockControls, + RichTextShortcut, +} from '@wordpress/block-editor'; +import { + Toolbar, +} from '@wordpress/components'; +import { + __unstableIndentListItems as indentListItems, + __unstableOutdentListItems as outdentListItems, + __unstableChangeListType as changeListType, + __unstableIsListRootSelected as isListRootSelected, + __unstableIsActiveListType as isActiveListType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { name } from './'; +import OrderedListSettings from './ordered-list-settings'; + +export default function ListEdit( { + attributes, + setAttributes, + mergeBlocks, + onReplace, + className, +} ) { + const { ordered, values, reversed, start } = attributes; + const tagName = ordered ? 'ol' : 'ul'; + + const controls = ( { value, onChange } ) => ( + <> + <RichTextShortcut + type="primary" + character="[" + onUse={ () => { + onChange( outdentListItems( value ) ); + } } + /> + <RichTextShortcut + type="primary" + character="]" + onUse={ () => { + onChange( indentListItems( value, { type: tagName } ) ); + } } + /> + <RichTextShortcut + type="primary" + character="m" + onUse={ () => { + onChange( indentListItems( value, { type: tagName } ) ); + } } + /> + <RichTextShortcut + type="primaryShift" + character="m" + onUse={ () => { + onChange( outdentListItems( value ) ); + } } + /> + <BlockControls> + <Toolbar + controls={ [ + { + icon: 'editor-ul', + title: __( 'Convert to unordered list' ), + isActive: isActiveListType( value, 'ul', tagName ), + onClick() { + onChange( changeListType( value, { type: 'ul' } ) ); + + if ( isListRootSelected( value ) ) { + setAttributes( { ordered: false } ); + } + }, + }, + { + icon: 'editor-ol', + title: __( 'Convert to ordered list' ), + isActive: isActiveListType( value, 'ol', tagName ), + onClick() { + onChange( changeListType( value, { type: 'ol' } ) ); + + if ( isListRootSelected( value ) ) { + setAttributes( { ordered: true } ); + } + }, + }, + { + icon: 'editor-outdent', + title: __( 'Outdent list item' ), + shortcut: _x( 'Backspace', 'keyboard key' ), + onClick() { + onChange( outdentListItems( value ) ); + }, + }, + { + icon: 'editor-indent', + title: __( 'Indent list item' ), + shortcut: _x( 'Space', 'keyboard key' ), + onClick() { + onChange( indentListItems( value, { type: tagName } ) ); + }, + }, + ] } + /> + </BlockControls> + </> + ); + + return <> + <RichText + identifier="values" + multiline="li" + tagName={ tagName } + onChange={ ( nextValues ) => setAttributes( { values: nextValues } ) } + value={ values } + wrapperClassName="block-library-list" + className={ className } + placeholder={ __( 'Write list…' ) } + onMerge={ mergeBlocks } + onSplit={ ( value ) => createBlock( name, { ...attributes, values: value } ) } + __unstableOnSplitMiddle={ () => createBlock( 'core/paragraph' ) } + onReplace={ onReplace } + onRemove={ () => onReplace( [] ) } + start={ start } + reversed={ reversed } + > + { controls } + </RichText> + { ordered && ( + <OrderedListSettings + setAttributes={ setAttributes } + ordered={ ordered } + reversed={ reversed } + start={ start } + /> ) } + </>; +} diff --git a/gb-src/packages/block-library/src/list/editor.scss b/gb-src/packages/block-library/src/list/editor.scss new file mode 100644 index 0000000000000..a9dd5efb6460b --- /dev/null +++ b/gb-src/packages/block-library/src/list/editor.scss @@ -0,0 +1,5 @@ +.editor-styles-wrapper .block-library-list ul, +.editor-styles-wrapper .block-library-list ol { + padding-left: 1.3em; + margin-left: 1.3em; +} diff --git a/gb-src/packages/block-library/src/list/icon.js b/gb-src/packages/block-library/src/list/icon.js new file mode 100644 index 0000000000000..cbc4c53631c2a --- /dev/null +++ b/gb-src/packages/block-library/src/list/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><G><Path d="M9 19h12v-2H9v2zm0-6h12v-2H9v2zm0-8v2h12V5H9zm-4-.5c-.828 0-1.5.672-1.5 1.5S4.172 7.5 5 7.5 6.5 6.828 6.5 6 5.828 4.5 5 4.5zm0 6c-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5-.672-1.5-1.5-1.5zm0 6c-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5-.672-1.5-1.5-1.5z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/list/index.js b/gb-src/packages/block-library/src/list/index.js new file mode 100644 index 0000000000000..f2423db6f3225 --- /dev/null +++ b/gb-src/packages/block-library/src/list/index.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'List' ), + description: __( 'Create a bulleted or numbered list.' ), + icon, + keywords: [ __( 'bullet list' ), __( 'ordered list' ), __( 'numbered list' ) ], + supports: { + className: false, + }, + example: { + attributes: { + values: '<li>Alice.</li><li>The White Rabbit.</li><li>The Cheshire Cat.</li><li>The Mad Hatter.</li><li>The Queen of Hearts.</li>', + }, + }, + transforms, + merge( attributes, attributesToMerge ) { + const { values } = attributesToMerge; + + if ( ! values || values === '<li></li>' ) { + return attributes; + } + + return { + ...attributes, + values: attributes.values + values, + }; + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/list/ordered-list-settings.js b/gb-src/packages/block-library/src/list/ordered-list-settings.js new file mode 100644 index 0000000000000..4cb845f423d65 --- /dev/null +++ b/gb-src/packages/block-library/src/list/ordered-list-settings.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import { + TextControl, + PanelBody, + ToggleControl, +} from '@wordpress/components'; + +const OrderedListSettings = ( { setAttributes, reversed, start } ) => ( + <InspectorControls> + <PanelBody title={ __( 'Ordered List Settings' ) }> + <TextControl + label={ __( 'Start Value' ) } + type="number" + onChange={ ( value ) => { + const int = parseInt( value, 10 ); + + setAttributes( { + // It should be possible to unset the value, + // e.g. with an empty string. + start: isNaN( int ) ? undefined : int, + } ); + } } + value={ Number.isInteger( start ) ? start.toString( 10 ) : '' } + step="1" + /> + <ToggleControl + label={ __( 'Reverse List Numbering' ) } + checked={ reversed || false } + onChange={ ( value ) => { + setAttributes( { + // Unset the attribute if not reversed. + reversed: value || undefined, + } ); + } } + /> + </PanelBody> + </InspectorControls> ); + +export default OrderedListSettings; diff --git a/gb-src/packages/block-library/src/list/ordered-list-settings.native.js b/gb-src/packages/block-library/src/list/ordered-list-settings.native.js new file mode 100644 index 0000000000000..43bab31a4330e --- /dev/null +++ b/gb-src/packages/block-library/src/list/ordered-list-settings.native.js @@ -0,0 +1,3 @@ +// Mobile has no additional list settings at this time, so render nothing +const AdditionalSettings = () => null; +export default AdditionalSettings; diff --git a/gb-src/packages/block-library/src/list/save.js b/gb-src/packages/block-library/src/list/save.js new file mode 100644 index 0000000000000..18458c2c1ce5a --- /dev/null +++ b/gb-src/packages/block-library/src/list/save.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { ordered, values, reversed, start } = attributes; + const tagName = ordered ? 'ol' : 'ul'; + + return ( + <RichText.Content + tagName={ tagName } + value={ values } + reversed={ reversed } + start={ start } + multiline="li" + /> + ); +} diff --git a/gb-src/packages/block-library/src/list/transforms.js b/gb-src/packages/block-library/src/list/transforms.js new file mode 100644 index 0000000000000..baf09b1b945c6 --- /dev/null +++ b/gb-src/packages/block-library/src/list/transforms.js @@ -0,0 +1,144 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getBlockAttributes, + getPhrasingContentSchema, +} from '@wordpress/blocks'; +import { + __UNSTABLE_LINE_SEPARATOR, + create, + join, + replace, + split, + toHTMLString, +} from '@wordpress/rich-text'; + +const listContentSchema = { + ...getPhrasingContentSchema(), + ul: {}, + ol: { attributes: [ 'type' ] }, +}; + +// Recursion is needed. +// Possible: ul > li > ul. +// Impossible: ul > ul. +[ 'ul', 'ol' ].forEach( ( tag ) => { + listContentSchema[ tag ].children = { + li: { + children: listContentSchema, + }, + }; +} ); + +const transforms = { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ 'core/paragraph' ], + transform: ( blockAttributes ) => { + return createBlock( 'core/list', { + values: toHTMLString( { + value: join( blockAttributes.map( ( { content } ) => { + const value = create( { html: content } ); + + if ( blockAttributes.length > 1 ) { + return value; + } + + // When converting only one block, transform + // every line to a list item. + return replace( value, /\n/g, __UNSTABLE_LINE_SEPARATOR ); + } ), __UNSTABLE_LINE_SEPARATOR ), + multilineTag: 'li', + } ), + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/quote' ], + transform: ( { value } ) => { + return createBlock( 'core/list', { + values: toHTMLString( { + value: create( { html: value, multilineTag: 'p' } ), + multilineTag: 'li', + } ), + } ); + }, + }, + { + type: 'raw', + selector: 'ol,ul', + schema: { + ol: listContentSchema.ol, + ul: listContentSchema.ul, + }, + transform( node ) { + return createBlock( 'core/list', { + ...getBlockAttributes( + 'core/list', + node.outerHTML + ), + ordered: node.nodeName === 'OL', + } ); + }, + }, + ...[ '*', '-' ].map( ( prefix ) => ( { + type: 'prefix', + prefix, + transform( content ) { + return createBlock( 'core/list', { + values: `<li>${ content }</li>`, + } ); + }, + } ) ), + ...[ '1.', '1)' ].map( ( prefix ) => ( { + type: 'prefix', + prefix, + transform( content ) { + return createBlock( 'core/list', { + ordered: true, + values: `<li>${ content }</li>`, + } ); + }, + } ) ), + ], + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { values } ) => + split( create( { + html: values, + multilineTag: 'li', + multilineWrapperTags: [ 'ul', 'ol' ], + } ), __UNSTABLE_LINE_SEPARATOR ) + .map( ( piece ) => + createBlock( 'core/paragraph', { + content: toHTMLString( { value: piece } ), + } ) + ), + }, + { + type: 'block', + blocks: [ 'core/quote' ], + transform: ( { values } ) => { + return createBlock( 'core/quote', { + value: toHTMLString( { + value: create( { + html: values, + multilineTag: 'li', + multilineWrapperTags: [ 'ul', 'ol' ], + } ), + multilineTag: 'p', + } ), + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/media-text/block.json b/gb-src/packages/block-library/src/media-text/block.json new file mode 100644 index 0000000000000..6eda1f4d5969e --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/block.json @@ -0,0 +1,56 @@ +{ + "name": "core/media-text", + "category": "layout", + "attributes": { + "align": { + "type": "string", + "default": "wide" + }, + "backgroundColor": { + "type": "string" + }, + "customBackgroundColor": { + "type": "string" + }, + "mediaAlt": { + "type": "string", + "source": "attribute", + "selector": "figure img", + "attribute": "alt", + "default": "" + }, + "mediaPosition": { + "type": "string", + "default": "left" + }, + "mediaId": { + "type": "number" + }, + "mediaUrl": { + "type": "string", + "source": "attribute", + "selector": "figure video,figure img", + "attribute": "src" + }, + "mediaType": { + "type": "string" + }, + "mediaWidth": { + "type": "number", + "default": 50 + }, + "isStackedOnMobile": { + "type": "boolean", + "default": false + }, + "verticalAlignment": { + "type": "string" + }, + "imageFill": { + "type": "boolean" + }, + "focalPoint": { + "type": "object" + } + } +} diff --git a/gb-src/packages/block-library/src/media-text/deprecated.js b/gb-src/packages/block-library/src/media-text/deprecated.js new file mode 100644 index 0000000000000..2123e259a97fa --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/deprecated.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + InnerBlocks, + getColorClassName, +} from '@wordpress/block-editor'; + +const DEFAULT_MEDIA_WIDTH = 50; + +export default [ + { + attributes: { + align: { + type: 'string', + default: 'wide', + }, + backgroundColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + mediaAlt: { + type: 'string', + source: 'attribute', + selector: 'figure img', + attribute: 'alt', + default: '', + }, + mediaPosition: { + type: 'string', + default: 'left', + }, + mediaId: { + type: 'number', + }, + mediaUrl: { + type: 'string', + source: 'attribute', + selector: 'figure video,figure img', + attribute: 'src', + }, + mediaType: { + type: 'string', + }, + mediaWidth: { + type: 'number', + default: 50, + }, + isStackedOnMobile: { + type: 'boolean', + default: false, + }, + }, + save( { attributes } ) { + const { + backgroundColor, + customBackgroundColor, + isStackedOnMobile, + mediaAlt, + mediaPosition, + mediaType, + mediaUrl, + mediaWidth, + } = attributes; + const mediaTypeRenders = { + image: () => <img src={ mediaUrl } alt={ mediaAlt } />, + video: () => <video controls src={ mediaUrl } />, + }; + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const className = classnames( { + 'has-media-on-the-right': 'right' === mediaPosition, + [ backgroundClass ]: backgroundClass, + 'is-stacked-on-mobile': isStackedOnMobile, + } ); + + let gridTemplateColumns; + if ( mediaWidth !== DEFAULT_MEDIA_WIDTH ) { + gridTemplateColumns = 'right' === mediaPosition ? `auto ${ mediaWidth }%` : `${ mediaWidth }% auto`; + } + const style = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + gridTemplateColumns, + }; + return ( + <div className={ className } style={ style }> + <figure className="wp-block-media-text__media" > + { ( mediaTypeRenders[ mediaType ] || noop )() } + </figure> + <div className="wp-block-media-text__content"> + <InnerBlocks.Content /> + </div> + </div> + ); + }, + }, +]; diff --git a/gb-src/packages/block-library/src/media-text/edit.js b/gb-src/packages/block-library/src/media-text/edit.js new file mode 100644 index 0000000000000..94495a7d635d6 --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/edit.js @@ -0,0 +1,247 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { + BlockControls, + BlockVerticalAlignmentToolbar, + InnerBlocks, + InspectorControls, + PanelColorSettings, + withColors, +} from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { + PanelBody, + TextareaControl, + ToggleControl, + Toolbar, + ExternalLink, + FocalPointPicker, +} from '@wordpress/components'; +/** + * Internal dependencies + */ +import MediaContainer from './media-container'; + +/** + * Constants + */ +const TEMPLATE = [ + [ 'core/paragraph', { fontSize: 'large', placeholder: _x( 'Content…', 'content placeholder' ) } ], +]; +// this limits the resize to a safe zone to avoid making broken layouts +const WIDTH_CONSTRAINT_PERCENTAGE = 15; +const applyWidthConstraints = ( width ) => Math.max( WIDTH_CONSTRAINT_PERCENTAGE, Math.min( width, 100 - WIDTH_CONSTRAINT_PERCENTAGE ) ); + +class MediaTextEdit extends Component { + constructor() { + super( ...arguments ); + + this.onSelectMedia = this.onSelectMedia.bind( this ); + this.onWidthChange = this.onWidthChange.bind( this ); + this.commitWidthChange = this.commitWidthChange.bind( this ); + this.state = { + mediaWidth: null, + }; + } + + onSelectMedia( media ) { + const { setAttributes } = this.props; + + let mediaType; + let src; + // for media selections originated from a file upload. + if ( media.media_type ) { + if ( media.media_type === 'image' ) { + mediaType = 'image'; + } else { + // only images and videos are accepted so if the media_type is not an image we can assume it is a video. + // video contain the media type of 'file' in the object returned from the rest api. + mediaType = 'video'; + } + } else { // for media selections originated from existing files in the media library. + mediaType = media.type; + } + + if ( mediaType === 'image' ) { + // Try the "large" size URL, falling back to the "full" size URL below. + src = get( media, [ 'sizes', 'large', 'url' ] ) || get( media, [ 'media_details', 'sizes', 'large', 'source_url' ] ); + } + + setAttributes( { + mediaAlt: media.alt, + mediaId: media.id, + mediaType, + mediaUrl: src || media.url, + imageFill: undefined, + focalPoint: undefined, + } ); + } + + onWidthChange( width ) { + this.setState( { + mediaWidth: applyWidthConstraints( width ), + } ); + } + + commitWidthChange( width ) { + const { setAttributes } = this.props; + + setAttributes( { + mediaWidth: applyWidthConstraints( width ), + } ); + this.setState( { + mediaWidth: null, + } ); + } + + renderMediaArea() { + const { attributes } = this.props; + const { mediaAlt, mediaId, mediaPosition, mediaType, mediaUrl, mediaWidth, imageFill, focalPoint } = attributes; + + return ( + <MediaContainer + className="block-library-media-text__media-container" + onSelectMedia={ this.onSelectMedia } + onWidthChange={ this.onWidthChange } + commitWidthChange={ this.commitWidthChange } + { ...{ mediaAlt, mediaId, mediaType, mediaUrl, mediaPosition, mediaWidth, imageFill, focalPoint } } + /> + ); + } + + render() { + const { + attributes, + className, + backgroundColor, + isSelected, + setAttributes, + setBackgroundColor, + } = this.props; + const { + isStackedOnMobile, + mediaAlt, + mediaPosition, + mediaType, + mediaWidth, + verticalAlignment, + mediaUrl, + imageFill, + focalPoint, + } = attributes; + const temporaryMediaWidth = this.state.mediaWidth; + const classNames = classnames( className, { + 'has-media-on-the-right': 'right' === mediaPosition, + 'is-selected': isSelected, + 'has-background': ( backgroundColor.class || backgroundColor.color ), + [ backgroundColor.class ]: backgroundColor.class, + 'is-stacked-on-mobile': isStackedOnMobile, + [ `is-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + 'is-image-fill': imageFill, + } ); + const widthString = `${ temporaryMediaWidth || mediaWidth }%`; + const gridTemplateColumns = 'right' === mediaPosition ? `1fr ${ widthString }` : `${ widthString } 1fr`; + const style = { + gridTemplateColumns, + msGridColumns: gridTemplateColumns, + backgroundColor: backgroundColor.color, + }; + const colorSettings = [ { + value: backgroundColor.color, + onChange: setBackgroundColor, + label: __( 'Background Color' ), + } ]; + const toolbarControls = [ { + icon: 'align-pull-left', + title: __( 'Show media on left' ), + isActive: mediaPosition === 'left', + onClick: () => setAttributes( { mediaPosition: 'left' } ), + }, { + icon: 'align-pull-right', + title: __( 'Show media on right' ), + isActive: mediaPosition === 'right', + onClick: () => setAttributes( { mediaPosition: 'right' } ), + } ]; + const onMediaAltChange = ( newMediaAlt ) => { + setAttributes( { mediaAlt: newMediaAlt } ); + }; + const onVerticalAlignmentChange = ( alignment ) => { + setAttributes( { verticalAlignment: alignment } ); + }; + const mediaTextGeneralSettings = ( + <PanelBody title={ __( 'Media & Text Settings' ) }> + <ToggleControl + label={ __( 'Stack on mobile' ) } + checked={ isStackedOnMobile } + onChange={ () => setAttributes( { + isStackedOnMobile: ! isStackedOnMobile, + } ) } + /> + { mediaType === 'image' && ( <ToggleControl + label={ __( 'Crop image to fill entire column' ) } + checked={ imageFill } + onChange={ () => setAttributes( { + imageFill: ! imageFill, + } ) } + /> ) } + { imageFill && ( <FocalPointPicker + label={ __( 'Focal Point Picker' ) } + url={ mediaUrl } + value={ focalPoint } + onChange={ ( value ) => setAttributes( { focalPoint: value } ) } + /> ) } + { mediaType === 'image' && ( <TextareaControl + label={ __( 'Alt Text (Alternative Text)' ) } + value={ mediaAlt } + onChange={ onMediaAltChange } + help={ + <> + <ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree"> + { __( 'Describe the purpose of the image' ) } + </ExternalLink> + { __( 'Leave empty if the image is purely decorative.' ) } + </> + } + /> ) } + </PanelBody> + ); + return ( + <> + <InspectorControls> + { mediaTextGeneralSettings } + <PanelColorSettings + title={ __( 'Color Settings' ) } + initialOpen={ false } + colorSettings={ colorSettings } + /> + </InspectorControls> + <BlockControls> + <Toolbar + controls={ toolbarControls } + /> + <BlockVerticalAlignmentToolbar + onChange={ onVerticalAlignmentChange } + value={ verticalAlignment } + /> + </BlockControls> + <div className={ classNames } style={ style } > + { this.renderMediaArea() } + <InnerBlocks + template={ TEMPLATE } + templateInsertUpdatesSelection={ false } + /> + </div> + </> + ); + } +} + +export default withColors( 'backgroundColor' )( MediaTextEdit ); diff --git a/gb-src/packages/block-library/src/media-text/editor.scss b/gb-src/packages/block-library/src/media-text/editor.scss new file mode 100644 index 0000000000000..5f6cabb151f90 --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/editor.scss @@ -0,0 +1,114 @@ +.wp-block-media-text .__resizable_base__ { + grid-column: 1 / span 2; + grid-row: 2; +} + +.wp-block-media-text.is-vertically-aligned-top { + .block-editor-inner-blocks, + .editor-media-container__resizer { + align-self: start; + } +} +.wp-block-media-text, +.wp-block-media-text.is-vertically-aligned-center { + .block-editor-inner-blocks, + .editor-media-container__resizer { + align-self: center; + } +} + +.wp-block-media-text.is-vertically-aligned-bottom { + .block-editor-inner-blocks, + .editor-media-container__resizer { + align-self: end; + } +} + +.wp-block-media-text .editor-media-container__resizer { + grid-column: 1; + grid-row: 1; + // The resizer sets a inline width but as we are using a grid layout, + // we set the width on container. + width: 100% !important; +} + +.wp-block-media-text.is-image-fill .editor-media-container__resizer { + // The resizer sets an inline height but for the image fill we set it to full height. + height: 100% !important; +} + +.wp-block-media-text.has-media-on-the-right .editor-media-container__resizer { + grid-column: 2; + grid-row: 1; +} + +.wp-block-media-text .editor-inner-blocks { + word-break: break-word; + grid-column: 2; + grid-row: 1; + text-align: initial; + padding: 0 8% 0 8%; +} + +.wp-block-media-text.has-media-on-the-right .block-editor-inner-blocks { + grid-column: 1; + grid-row: 1; +} + +.wp-block-media-text > .block-editor-inner-blocks > .block-editor-block-list__layout > .block-editor-block-list__block { + max-width: unset; +} + +figure.block-library-media-text__media-container { + margin: 0; + height: 100%; + width: 100%; +} + +.wp-block-media-text .block-library-media-text__media-container img, +.wp-block-media-text .block-library-media-text__media-container video { + vertical-align: middle; + width: 100%; +} + +.editor-media-container__resizer .components-resizable-box__handle { + display: none; +} + +.wp-block-media-text.is-selected:not(.is-stacked-on-mobile) { + .editor-media-container__resizer .components-resizable-box__handle { + display: block; + } +} + +@include break-small { + .wp-block-media-text.is-selected.is-stacked-on-mobile { + .editor-media-container__resizer .components-resizable-box__handle { + display: block; + } + } +} + +@media (max-width: #{ ($break-small) }) { + .wp-block-media-text.is-stacked-on-mobile { + .block-editor-inner-blocks { + grid-column: 1; + grid-row: 2; + } + .editor-media-container__resizer { + grid-column: 1; + grid-row: 1; + } + } + .wp-block-media-text.is-stacked-on-mobile.has-media-on-the-right { + .block-editor-inner-blocks { + grid-column: 1; + grid-row: 1; + } + .editor-media-container__resizer { + grid-column: 1; + grid-row: 2; + } + } +} + diff --git a/gb-src/packages/block-library/src/media-text/icon.js b/gb-src/packages/block-library/src/media-text/icon.js new file mode 100644 index 0000000000000..cfc1f405b34cc --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M13 17h8v-2h-8v2zM3 19h8V5H3v14zM13 9h8V7h-8v2zm0 4h8v-2h-8v2z" /></SVG>; diff --git a/gb-src/packages/block-library/src/media-text/index.js b/gb-src/packages/block-library/src/media-text/index.js new file mode 100644 index 0000000000000..73f1ee2080a1b --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/index.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Media & Text' ), + description: __( 'Set media and words side-by-side for a richer layout.' ), + icon, + keywords: [ __( 'image' ), __( 'video' ) ], + supports: { + align: [ 'wide', 'full' ], + html: false, + }, + example: { + attributes: { + mediaType: 'image', + mediaUrl: 'https://s.w.org/images/core/5.3/Biologia_Centrali-Americana_-_Cantorchilus_semibadius_1902.jpg', + }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: __( 'The wren<br>Earns his living<br>Noiselessly.' ), + }, + }, + { + name: 'core/paragraph', + attributes: { + content: __( '— Kobayashi Issa (一茶)' ), + }, + }, + ], + }, + transforms, + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/media-text/media-container-icon.js b/gb-src/packages/block-library/src/media-text/media-container-icon.js new file mode 100644 index 0000000000000..6e6de30b7a61d --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/media-container-icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M18 2l2 4h-2l-2-4h-3l2 4h-2l-2-4h-1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V2zm2 12H10V4.4L11.8 8H20z" /><Path d="M14 20H4V10h3V8H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3h-2z" /><Path d="M5 19h8l-1.59-2H9.24l-.84 1.1L7 16.3 5 19z" /></SVG>; diff --git a/gb-src/packages/block-library/src/media-text/media-container.js b/gb-src/packages/block-library/src/media-text/media-container.js new file mode 100644 index 0000000000000..fce35487d6a03 --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/media-container.js @@ -0,0 +1,169 @@ +/** + * WordPress dependencies + */ +import { IconButton, ResizableBox, Toolbar, withNotices } from '@wordpress/components'; +import { + BlockControls, + BlockIcon, + MediaPlaceholder, + MediaUpload, +} from '@wordpress/block-editor'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; +import { withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import icon from './media-container-icon'; + +/** + * Constants + */ +const ALLOWED_MEDIA_TYPES = [ 'image', 'video' ]; + +export function imageFillStyles( url, focalPoint ) { + return url ? + { + backgroundImage: `url(${ url })`, + backgroundPosition: focalPoint ? `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%` : `50% 50%`, + } : + {}; +} + +class MediaContainer extends Component { + constructor() { + super( ...arguments ); + this.onUploadError = this.onUploadError.bind( this ); + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + renderToolbarEditButton() { + const { mediaId, onSelectMedia } = this.props; + return ( + <BlockControls> + <Toolbar> + <MediaUpload + onSelect={ onSelectMedia } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ mediaId } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit media' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + </BlockControls> + ); + } + + renderImage() { + const { mediaAlt, mediaUrl, className, imageFill, focalPoint } = this.props; + const backgroundStyles = imageFill ? imageFillStyles( mediaUrl, focalPoint ) : {}; + return ( + <> + { this.renderToolbarEditButton() } + <figure className={ className } style={ backgroundStyles }> + <img src={ mediaUrl } alt={ mediaAlt } /> + </figure> + </> + ); + } + + renderVideo() { + const { mediaUrl, className } = this.props; + return ( + <> + { this.renderToolbarEditButton() } + <figure className={ className }> + <video controls src={ mediaUrl } /> + </figure> + </> + ); + } + + renderPlaceholder() { + const { onSelectMedia, className, noticeUI } = this.props; + return ( + <MediaPlaceholder + icon={ <BlockIcon icon={ icon } /> } + labels={ { + title: __( 'Media area' ), + } } + className={ className } + onSelect={ onSelectMedia } + accept="image/*,video/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + notices={ noticeUI } + onError={ this.onUploadError } + /> + ); + } + + render() { + const { mediaPosition, mediaUrl, mediaType, mediaWidth, commitWidthChange, onWidthChange, toggleSelection } = this.props; + if ( mediaType && mediaUrl ) { + const onResizeStart = () => { + toggleSelection( false ); + }; + const onResize = ( event, direction, elt ) => { + onWidthChange( parseInt( elt.style.width ) ); + }; + const onResizeStop = ( event, direction, elt ) => { + toggleSelection( true ); + commitWidthChange( parseInt( elt.style.width ) ); + }; + const enablePositions = { + right: mediaPosition === 'left', + left: mediaPosition === 'right', + }; + + let mediaElement = null; + switch ( mediaType ) { + case 'image': + mediaElement = this.renderImage(); + break; + case 'video': + mediaElement = this.renderVideo(); + break; + } + return ( + <ResizableBox + className="editor-media-container__resizer" + size={ { width: mediaWidth + '%' } } + minWidth="10%" + maxWidth="100%" + enable={ enablePositions } + onResizeStart={ onResizeStart } + onResize={ onResize } + onResizeStop={ onResizeStop } + axis="x" + > + { mediaElement } + </ResizableBox> + ); + } + return this.renderPlaceholder(); + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { toggleSelection } = dispatch( 'core/block-editor' ); + + return { + toggleSelection, + }; + } ), + withNotices, +] )( MediaContainer ); diff --git a/gb-src/packages/block-library/src/media-text/save.js b/gb-src/packages/block-library/src/media-text/save.js new file mode 100644 index 0000000000000..8d4529173948e --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/save.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + InnerBlocks, + getColorClassName, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { imageFillStyles } from './media-container'; + +const DEFAULT_MEDIA_WIDTH = 50; + +export default function save( { attributes } ) { + const { + backgroundColor, + customBackgroundColor, + isStackedOnMobile, + mediaAlt, + mediaPosition, + mediaType, + mediaUrl, + mediaWidth, + mediaId, + verticalAlignment, + imageFill, + focalPoint, + } = attributes; + const mediaTypeRenders = { + image: () => <img src={ mediaUrl } alt={ mediaAlt } className={ ( mediaId && mediaType === 'image' ) ? `wp-image-${ mediaId }` : null } />, + video: () => <video controls src={ mediaUrl } />, + }; + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const className = classnames( { + 'has-media-on-the-right': 'right' === mediaPosition, + 'has-background': ( backgroundClass || customBackgroundColor ), + [ backgroundClass ]: backgroundClass, + 'is-stacked-on-mobile': isStackedOnMobile, + [ `is-vertically-aligned-${ verticalAlignment }` ]: verticalAlignment, + 'is-image-fill': imageFill, + } ); + const backgroundStyles = imageFill ? imageFillStyles( mediaUrl, focalPoint ) : {}; + + let gridTemplateColumns; + if ( mediaWidth !== DEFAULT_MEDIA_WIDTH ) { + gridTemplateColumns = 'right' === mediaPosition ? `auto ${ mediaWidth }%` : `${ mediaWidth }% auto`; + } + const style = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + gridTemplateColumns, + }; + return ( + <div className={ className } style={ style }> + <figure className="wp-block-media-text__media" style={ backgroundStyles }> + { ( mediaTypeRenders[ mediaType ] || noop )() } + </figure> + <div className="wp-block-media-text__content"> + <InnerBlocks.Content /> + </div> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/media-text/style.scss b/gb-src/packages/block-library/src/media-text/style.scss new file mode 100644 index 0000000000000..c06e92a88844e --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/style.scss @@ -0,0 +1,108 @@ +.wp-block-media-text { + display: grid; + grid-template-rows: auto; + grid-template-columns: 50% 1fr; + .has-media-on-the-right { + grid-template-columns: 1fr 50%; + } +} + +.wp-block-media-text.is-vertically-aligned-top { + .wp-block-media-text__content, + .wp-block-media-text__media { + align-self: start; + } +} +.wp-block-media-text, +.wp-block-media-text.is-vertically-aligned-center { + .wp-block-media-text__content, + .wp-block-media-text__media { + align-self: center; + } +} + +.wp-block-media-text.is-vertically-aligned-bottom { + .wp-block-media-text__content, + .wp-block-media-text__media { + align-self: end; + } +} + +.wp-block-media-text .wp-block-media-text__media { + grid-column: 1; + grid-row: 1; + margin: 0; +} + +.wp-block-media-text .wp-block-media-text__content { + grid-column: 2; + grid-row: 1; + word-break: break-word; + padding: 0 8% 0 8%; +} + +.wp-block-media-text.has-media-on-the-right .wp-block-media-text__media { + grid-column: 2; + grid-row: 1; +} + +.wp-block-media-text.has-media-on-the-right .wp-block-media-text__content { + grid-column: 1; + grid-row: 1; +} + +.wp-block-media-text > figure > img, +.wp-block-media-text > figure > video { + max-width: unset; + width: 100%; + vertical-align: middle; +} + +.wp-block-media-text.is-image-fill figure { + height: 100%; + min-height: 250px; + background-size: cover; +} + +.wp-block-media-text.is-image-fill figure > img { + // The image is visually hidden but accessible to assistive technologies. + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +/* +* Here we here not able to use a mobile first CSS approach. +* Custom widths are set using inline styles, and on mobile, +* we need 100% width, so we use important to overwrite the inline style. +* If the style were set on mobile first, on desktop styles, +* we would have no way of setting the style again to the inline style. +*/ +@media (max-width: #{ ($break-small) }) { + .wp-block-media-text.is-stacked-on-mobile { + grid-template-columns: 100% !important; + .wp-block-media-text__media { + grid-column: 1; + grid-row: 1; + } + .wp-block-media-text__content { + grid-column: 1; + grid-row: 2; + } + } + + .wp-block-media-text.is-stacked-on-mobile.has-media-on-the-right { + .wp-block-media-text__media { + grid-column: 1; + grid-row: 2; + } + .wp-block-media-text__content { + grid-column: 1; + grid-row: 1; + } + } +} diff --git a/gb-src/packages/block-library/src/media-text/transforms.js b/gb-src/packages/block-library/src/media-text/transforms.js new file mode 100644 index 0000000000000..f14246bed01b6 --- /dev/null +++ b/gb-src/packages/block-library/src/media-text/transforms.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( { alt, url, id } ) => ( + createBlock( 'core/media-text', { + mediaAlt: alt, + mediaId: id, + mediaUrl: url, + mediaType: 'image', + } ) + ), + }, + { + type: 'block', + blocks: [ 'core/video' ], + transform: ( { src, id } ) => ( + createBlock( 'core/media-text', { + mediaId: id, + mediaUrl: src, + mediaType: 'video', + } ) + ), + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/image' ], + isMatch: ( { mediaType, mediaUrl } ) => { + return ! mediaUrl || mediaType === 'image'; + }, + transform: ( { mediaAlt, mediaId, mediaUrl } ) => { + return createBlock( 'core/image', { + alt: mediaAlt, + id: mediaId, + url: mediaUrl, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/video' ], + isMatch: ( { mediaType, mediaUrl } ) => { + return ! mediaUrl || mediaType === 'video'; + }, + transform: ( { mediaId, mediaUrl } ) => { + return createBlock( 'core/video', { + id: mediaId, + src: mediaUrl, + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/missing/block.json b/gb-src/packages/block-library/src/missing/block.json new file mode 100644 index 0000000000000..46d919560865e --- /dev/null +++ b/gb-src/packages/block-library/src/missing/block.json @@ -0,0 +1,16 @@ +{ + "name": "core/missing", + "category": "common", + "attributes": { + "originalName": { + "type": "string" + }, + "originalUndelimitedContent": { + "type": "string" + }, + "originalContent": { + "type": "string", + "source": "html" + } + } +} diff --git a/gb-src/packages/block-library/src/missing/edit.js b/gb-src/packages/block-library/src/missing/edit.js new file mode 100644 index 0000000000000..0917f0e0706ac --- /dev/null +++ b/gb-src/packages/block-library/src/missing/edit.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { RawHTML } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +import { getBlockType, createBlock } from '@wordpress/blocks'; +import { withDispatch } from '@wordpress/data'; +import { Warning } from '@wordpress/block-editor'; +import { safeHTML } from '@wordpress/dom'; + +function MissingBlockWarning( { attributes, convertToHTML } ) { + const { originalName, originalUndelimitedContent } = attributes; + const hasContent = !! originalUndelimitedContent; + const hasHTMLBlock = getBlockType( 'core/html' ); + + const actions = []; + let messageHTML; + if ( hasContent && hasHTMLBlock ) { + messageHTML = sprintf( + __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely.' ), + originalName + ); + actions.push( + <Button key="convert" onClick={ convertToHTML } isLarge isPrimary> + { __( 'Keep as HTML' ) } + </Button> + ); + } else { + messageHTML = sprintf( + __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact or remove it entirely.' ), + originalName + ); + } + + return ( + <> + <Warning actions={ actions }> + { messageHTML } + </Warning> + <RawHTML>{ safeHTML( originalUndelimitedContent ) }</RawHTML> + </> + ); +} + +const MissingEdit = withDispatch( ( dispatch, { clientId, attributes } ) => { + const { replaceBlock } = dispatch( 'core/block-editor' ); + return { + convertToHTML() { + replaceBlock( clientId, createBlock( 'core/html', { + content: attributes.originalUndelimitedContent, + } ) ); + }, + }; +} )( MissingBlockWarning ); + +export default MissingEdit; diff --git a/gb-src/packages/block-library/src/missing/edit.native.js b/gb-src/packages/block-library/src/missing/edit.native.js new file mode 100644 index 0000000000000..838c64d88b56c --- /dev/null +++ b/gb-src/packages/block-library/src/missing/edit.native.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { View, Text } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Icon } from '@wordpress/components'; +import { coreBlocks } from '@wordpress/block-library'; +import { normalizeIconObject } from '@wordpress/blocks'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export default class UnsupportedBlockEdit extends Component { + render() { + const { originalName } = this.props.attributes; + const blockType = coreBlocks[ originalName ]; + const title = blockType ? blockType.settings.title : __( 'Unsupported' ); + const icon = blockType ? normalizeIconObject( blockType.settings.icon ) : 'admin-plugins'; + + return ( + <View style={ styles.unsupportedBlock }> + <Icon className="unsupported-icon" icon={ icon && icon.src ? icon.src : icon } /> + <Text style={ styles.unsupportedBlockMessage }>{ title }</Text> + </View> + ); + } +} diff --git a/gb-src/packages/block-library/src/missing/index.js b/gb-src/packages/block-library/src/missing/index.js new file mode 100644 index 0000000000000..301efedc3b826 --- /dev/null +++ b/gb-src/packages/block-library/src/missing/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + name, + title: __( 'Unrecognized Block' ), + description: __( 'Your site doesn’t include support for this block.' ), + supports: { + className: false, + customClassName: false, + inserter: false, + html: false, + reusable: false, + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/missing/index.native.js b/gb-src/packages/block-library/src/missing/index.native.js new file mode 100644 index 0000000000000..e902faef9b403 --- /dev/null +++ b/gb-src/packages/block-library/src/missing/index.native.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { coreBlocks } from '@wordpress/block-library'; + +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + __experimentalGetAccessibilityLabel( attributes ) { + const { originalName } = attributes; + + const originalBlockType = originalName && coreBlocks[ originalName ]; + + if ( originalBlockType ) { + return originalBlockType.settings.title || originalName; + } + + return ''; + }, +}; diff --git a/gb-src/packages/block-library/src/missing/save.js b/gb-src/packages/block-library/src/missing/save.js new file mode 100644 index 0000000000000..eb2c26d318236 --- /dev/null +++ b/gb-src/packages/block-library/src/missing/save.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save( { attributes } ) { + // Preserve the missing block's content. + return <RawHTML>{ attributes.originalContent }</RawHTML>; +} diff --git a/gb-src/packages/block-library/src/missing/style.native.scss b/gb-src/packages/block-library/src/missing/style.native.scss new file mode 100644 index 0000000000000..95a86122d7098 --- /dev/null +++ b/gb-src/packages/block-library/src/missing/style.native.scss @@ -0,0 +1,22 @@ +/** @format */ + +.unsupportedBlock { + background-color: #e9eff3; // grey lighten 30 + padding-top: 24; + padding-bottom: 24; + padding-left: 8; + padding-right: 8; + align-items: center; + justify-content: center; +} + +.unsupportedBlockIcon { + color: $gray-dark; +} + +.unsupportedBlockMessage { + margin-top: 2; + text-align: center; + color: $gray-dark; + font-size: 14; +} diff --git a/gb-src/packages/block-library/src/more/block.json b/gb-src/packages/block-library/src/more/block.json new file mode 100644 index 0000000000000..15cd328807682 --- /dev/null +++ b/gb-src/packages/block-library/src/more/block.json @@ -0,0 +1,13 @@ +{ + "name": "core/more", + "category": "layout", + "attributes": { + "customText": { + "type": "string" + }, + "noTeaser": { + "type": "boolean", + "default": false + } + } +} diff --git a/gb-src/packages/block-library/src/more/edit.js b/gb-src/packages/block-library/src/more/edit.js new file mode 100644 index 0000000000000..670ef8027e5e5 --- /dev/null +++ b/gb-src/packages/block-library/src/more/edit.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { PanelBody, ToggleControl } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { InspectorControls } from '@wordpress/block-editor'; +import { ENTER } from '@wordpress/keycodes'; +import { + getDefaultBlockName, + createBlock, +} from '@wordpress/blocks'; + +export default class MoreEdit extends Component { + constructor() { + super( ...arguments ); + this.onChangeInput = this.onChangeInput.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + + this.state = { + defaultText: __( 'Read more' ), + }; + } + + onChangeInput( event ) { + // Set defaultText to an empty string, allowing the user to clear/replace the input field's text + this.setState( { + defaultText: '', + } ); + + const value = event.target.value.length === 0 ? undefined : event.target.value; + this.props.setAttributes( { customText: value } ); + } + + onKeyDown( event ) { + const { keyCode } = event; + const { insertBlocksAfter } = this.props; + if ( keyCode === ENTER ) { + insertBlocksAfter( [ createBlock( getDefaultBlockName() ) ] ); + } + } + + getHideExcerptHelp( checked ) { + return checked ? + __( 'The excerpt is hidden.' ) : + __( 'The excerpt is visible.' ); + } + + render() { + const { customText, noTeaser } = this.props.attributes; + const { setAttributes } = this.props; + + const toggleHideExcerpt = () => setAttributes( { noTeaser: ! noTeaser } ); + const { defaultText } = this.state; + const value = customText !== undefined ? customText : defaultText; + const inputLength = value.length + 1; + + return ( + <> + <InspectorControls> + <PanelBody> + <ToggleControl + label={ __( 'Hide the excerpt on the full content page' ) } + checked={ !! noTeaser } + onChange={ toggleHideExcerpt } + help={ this.getHideExcerptHelp } + /> + </PanelBody> + </InspectorControls> + <div className="wp-block-more"> + <input + type="text" + value={ value } + size={ inputLength } + onChange={ this.onChangeInput } + onKeyDown={ this.onKeyDown } + /> + </div> + </> + ); + } +} diff --git a/gb-src/packages/block-library/src/more/edit.native.js b/gb-src/packages/block-library/src/more/edit.native.js new file mode 100644 index 0000000000000..8b369284cb596 --- /dev/null +++ b/gb-src/packages/block-library/src/more/edit.native.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; +import Hr from 'react-native-hr'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import styles from './editor.scss'; + +export default class MoreEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + defaultText: __( 'Read more' ), + }; + } + + render() { + const { customText } = this.props.attributes; + const { defaultText } = this.state; + const content = customText || defaultText; + + return ( + <View> + <Hr + text={ content } + marginLeft={ 0 } + marginRight={ 0 } + textStyle={ styles[ 'block-library-more__text' ] } + lineStyle={ styles[ 'block-library-more__line' ] } + /> + </View> + ); + } +} diff --git a/gb-src/packages/block-library/src/more/editor.native.scss b/gb-src/packages/block-library/src/more/editor.native.scss new file mode 100644 index 0000000000000..eb4a1d60d9431 --- /dev/null +++ b/gb-src/packages/block-library/src/more/editor.native.scss @@ -0,0 +1,13 @@ +// @format + +.block-library-more__line { + background-color: $gray-lighten-20; + height: 2; +} + +.block-library-more__text { + color: $gray; + text-decoration-style: solid; + text-transform: uppercase; +} + diff --git a/gb-src/packages/block-library/src/more/editor.scss b/gb-src/packages/block-library/src/more/editor.scss new file mode 100644 index 0000000000000..5b5edb5b5c310 --- /dev/null +++ b/gb-src/packages/block-library/src/more/editor.scss @@ -0,0 +1,45 @@ +.block-editor-block-list__block[data-type="core/more"] { + max-width: 100%; + text-align: center; + margin-top: $default-block-margin; + margin-bottom: $default-block-margin; +} + +.block-editor .wp-block-more { // needs specificity + display: block; + text-align: center; + white-space: nowrap; + + // Label + input[type="text"] { + position: relative; + font-size: $default-font-size; + text-transform: uppercase; + font-weight: 600; + font-family: $default-font; + color: $dark-gray-300; + border: none; + box-shadow: none; + white-space: nowrap; + text-align: center; + margin: 0; + border-radius: 4px; + background: $white; + padding: 6px 8px; + height: $icon-button-size-small; + + &:focus { + box-shadow: none; + } + } + + // Dashed line + &::before { + content: ""; + position: absolute; + top: calc(50%); + left: 0; + right: 0; + border-top: 3px dashed $light-gray-700; + } +} diff --git a/gb-src/packages/block-library/src/more/icon.js b/gb-src/packages/block-library/src/more/icon.js new file mode 100644 index 0000000000000..b73e5cdf9b3f6 --- /dev/null +++ b/gb-src/packages/block-library/src/more/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M2 9v2h19V9H2zm0 6h5v-2H2v2zm7 0h5v-2H9v2zm7 0h5v-2h-5v2z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/more/index.js b/gb-src/packages/block-library/src/more/index.js new file mode 100644 index 0000000000000..d61cb2001ff49 --- /dev/null +++ b/gb-src/packages/block-library/src/more/index.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: _x( 'More', 'block name' ), + description: __( 'Content before this block will be shown in the excerpt on your archives page.' ), + icon, + supports: { + customClassName: false, + className: false, + html: false, + multiple: false, + }, + example: {}, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/more/index.native.js b/gb-src/packages/block-library/src/more/index.native.js new file mode 100644 index 0000000000000..1525b6694abe5 --- /dev/null +++ b/gb-src/packages/block-library/src/more/index.native.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + __experimentalGetAccessibilityLabel( attributes ) { + return attributes.customText; + }, +}; diff --git a/gb-src/packages/block-library/src/more/save.js b/gb-src/packages/block-library/src/more/save.js new file mode 100644 index 0000000000000..f3483924d85ff --- /dev/null +++ b/gb-src/packages/block-library/src/more/save.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { compact } from 'lodash'; + +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save( { attributes } ) { + const { customText, noTeaser } = attributes; + + const moreTag = customText ? + `<!--more ${ customText }-->` : + '<!--more-->'; + + const noTeaserTag = noTeaser ? + '<!--noteaser-->' : + ''; + + return ( + <RawHTML> + { compact( [ moreTag, noTeaserTag ] ).join( '\n' ) } + </RawHTML> + ); +} diff --git a/gb-src/packages/block-library/src/more/test/__snapshots__/edit.js.snap b/gb-src/packages/block-library/src/more/test/__snapshots__/edit.js.snap new file mode 100644 index 0000000000000..ace475450948e --- /dev/null +++ b/gb-src/packages/block-library/src/more/test/__snapshots__/edit.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`core/more/edit should match snapshot when noTeaser is false 1`] = ` +<React.Fragment> + <IfBlockEditSelected(InspectorControlsFill)> + <ForwardRef(PanelBody)> + <WithInstanceId(ToggleControl) + checked={false} + help={[Function]} + label="Hide the excerpt on the full content page" + onChange={[Function]} + /> + </ForwardRef(PanelBody)> + </IfBlockEditSelected(InspectorControlsFill)> + <div + className="wp-block-more" + > + <input + onChange={[Function]} + onKeyDown={[Function]} + size={1} + type="text" + value="" + /> + </div> +</React.Fragment> +`; + +exports[`core/more/edit should match snapshot when noTeaser is true 1`] = ` +<React.Fragment> + <IfBlockEditSelected(InspectorControlsFill)> + <ForwardRef(PanelBody)> + <WithInstanceId(ToggleControl) + checked={true} + help={[Function]} + label="Hide the excerpt on the full content page" + onChange={[Function]} + /> + </ForwardRef(PanelBody)> + </IfBlockEditSelected(InspectorControlsFill)> + <div + className="wp-block-more" + > + <input + onChange={[Function]} + onKeyDown={[Function]} + size={1} + type="text" + value="" + /> + </div> +</React.Fragment> +`; diff --git a/gb-src/packages/block-library/src/more/test/edit.js b/gb-src/packages/block-library/src/more/test/edit.js new file mode 100644 index 0000000000000..8c7bad4e170c1 --- /dev/null +++ b/gb-src/packages/block-library/src/more/test/edit.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import Shallow from 'react-test-renderer/shallow'; +const ShallowRenderer = new Shallow(); +/** + * Internal dependencies + */ +import MoreEdit from '../edit'; + +const attributes = { + customText: '', + noTeaser: false, +}; + +describe( 'core/more/edit', () => { + beforeEach( () => { + attributes.noTeaser = false; + } ); + test( 'should match snapshot when noTeaser is false', () => { + ShallowRenderer.render( <MoreEdit attributes={ attributes } /> ); + expect( ShallowRenderer.getRenderOutput() ).toMatchSnapshot(); + } ); + test( 'should match snapshot when noTeaser is true', () => { + attributes.noTeaser = true; + ShallowRenderer.render( <MoreEdit attributes={ attributes } /> ); + expect( ShallowRenderer.getRenderOutput() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/block-library/src/more/transforms.js b/gb-src/packages/block-library/src/more/transforms.js new file mode 100644 index 0000000000000..41cabf5183aff --- /dev/null +++ b/gb-src/packages/block-library/src/more/transforms.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'raw', + schema: { + 'wp-block': { attributes: [ 'data-block' ] }, + }, + isMatch: ( node ) => node.dataset && node.dataset.block === 'core/more', + transform( node ) { + const { customText, noTeaser } = node.dataset; + const attrs = {}; + // Don't copy unless defined and not an empty string + if ( customText ) { + attrs.customText = customText; + } + // Special handling for boolean + if ( noTeaser === '' ) { + attrs.noTeaser = true; + } + return createBlock( 'core/more', attrs ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/navigation-menu-item/block.json b/gb-src/packages/block-library/src/navigation-menu-item/block.json new file mode 100644 index 0000000000000..915c63931cb2b --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/block.json @@ -0,0 +1,26 @@ +{ + "name": "core/navigation-menu-item", + "category": "layout", + "attributes": { + "label": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "nofollow": { + "type": "boolean", + "default": false + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "opensInNewTab": { + "type": "boolean", + "default": false + } + } +} diff --git a/gb-src/packages/block-library/src/navigation-menu-item/edit.js b/gb-src/packages/block-library/src/navigation-menu-item/edit.js new file mode 100644 index 0000000000000..f8a3b23fd0420 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/edit.js @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { invoke } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Dropdown, + ExternalLink, + IconButton, + PanelBody, + TextareaControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + InnerBlocks, + InspectorControls, + PlainText, +} from '@wordpress/block-editor'; +import { + Fragment, + useCallback, + useRef, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import MenuItemActions from './menu-item-actions'; +const POPOVER_PROPS = { noArrow: true }; + +function NavigationMenuItemEdit( { + attributes, + clientId, + isSelected, + setAttributes, +} ) { + const plainTextRef = useRef( null ); + const onEditLableClicked = useCallback( + ( onClose ) => () => { + onClose(); + invoke( plainTextRef, [ 'current', 'textarea', 'focus' ] ); + }, + [ plainTextRef ] + ); + let content; + if ( isSelected ) { + content = ( + <div className="wp-block-navigation-menu-item__edit-container"> + <PlainText + ref={ plainTextRef } + className="wp-block-navigation-menu-item__field" + value={ attributes.label } + onChange={ ( label ) => setAttributes( { label } ) } + aria-label={ __( 'Navigation Label' ) } + maxRows={ 1 } + /> + <Dropdown + contentClassName="wp-block-navigation-menu-item__dropdown-content" + position="bottom left" + popoverProps={ POPOVER_PROPS } + renderToggle={ ( { isOpen, onToggle } ) => ( + <IconButton + icon={ isOpen ? 'arrow-up-alt2' : 'arrow-down-alt2' } + label={ __( 'More options' ) } + onClick={ onToggle } + aria-expanded={ isOpen } + /> + ) } + renderContent={ ( { onClose } ) => ( + <MenuItemActions + clientId={ clientId } + destination={ attributes.destination } + onEditLableClicked={ onEditLableClicked( onClose ) } + /> + ) } + /> + </div> + ); + } else { + content = attributes.label; + } + return ( + <Fragment> + <InspectorControls> + <PanelBody + title={ __( 'Menu Settings' ) } + > + <ToggleControl + checked={ attributes.opensInNewTab } + onChange={ ( opensInNewTab ) => { + setAttributes( { opensInNewTab } ); + } } + label={ __( 'Open in new tab' ) } + /> + <TextareaControl + value={ attributes.description || '' } + onChange={ ( description ) => { + setAttributes( { description } ); + } } + label={ __( 'Description' ) } + /> + </PanelBody> + <PanelBody + title={ __( 'SEO Settings' ) } + > + <TextControl + value={ attributes.title || '' } + onChange={ ( title ) => { + setAttributes( { title } ); + } } + label={ __( 'Title Attribute' ) } + help={ __( 'Provide more context about where the link goes.' ) } + /> + <ToggleControl + checked={ attributes.nofollow } + onChange={ ( nofollow ) => { + setAttributes( { nofollow } ); + } } + label={ __( 'Add nofollow to menu item' ) } + help={ ( + <Fragment> + { __( 'Don\'t let search engines follow this link.' ) } + <ExternalLink + className="wp-block-navigation-menu-item__nofollow-external-link" + href={ __( 'https://codex.wordpress.org/Nofollow' ) } + > + { __( 'What\'s this?' ) } + </ExternalLink> + </Fragment> + ) } + /> + </PanelBody> + </InspectorControls> + <div className="wp-block-navigation-menu-item"> + { content } + <InnerBlocks + allowedBlocks={ [ 'core/navigation-menu-item' ] } + renderAppender={ InnerBlocks.ButtonBlockAppender } + /> + </div> + </Fragment> + ); +} + +export default NavigationMenuItemEdit; diff --git a/gb-src/packages/block-library/src/navigation-menu-item/editor.scss b/gb-src/packages/block-library/src/navigation-menu-item/editor.scss new file mode 100644 index 0000000000000..51bd5b7ceae45 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/editor.scss @@ -0,0 +1,72 @@ +.wp-block-navigation-menu-item__edit-container { + display: grid; + grid-auto-columns: min-content; + grid-auto-flow: column; + align-items: center; + white-space: nowrap; +} + +$menu-label-field-width: 140px; +.wp-block-navigation-menu-item__edit-container { + border: 1px solid $light-gray-500; + // two pixes comes from two times one pixel border + width: $menu-label-field-width + $icon-button-size + 2px; + padding-left: 1px; +} + +.wp-block-navigation-menu-item__edit-container .wp-block-navigation-menu-item__field { + border-right: 1px solid $light-gray-500 !important; + width: $menu-label-field-width; + border: none; + border-radius: 0; + padding-left: $grid-size-large; + + min-height: $icon-button-size - 1px; + line-height: $icon-button-size - 1px; + + &, + &:focus { + color: $dark-gray-500; + } +} + + +.wp-block-navigation-menu-item { + font-family: $editor-font; + color: #0073af; + font-weight: bold; + font-size: $text-editor-font-size; +} + +.wp-block-navigation-menu-item__nofollow-external-link { + display: block; +} + +// Separator +.wp-block-navigation-menu-item__separator { + margin-top: $grid-size; + margin-bottom: $grid-size; + margin-left: 0; + margin-right: 0; + border-top: $border-width solid $light-gray-500; +} + +// Popover styles +.components-popover:not(.is-mobile).wp-block-navigation-menu-item__dropdown-content { + margin-top: -1px; + margin-left: -4px; +} + +.wp-block-navigation-menu-item__dropdown-content .components-popover__content { + padding: $grid-size 0; +} + +.wp-block-navigation-menu .block-editor-block-list__block[data-type="core/navigation-menu-item"] { + & > .block-editor-block-list__block-edit > div[role="toolbar"] { + display: none; + } + + & > .block-editor-block-list__insertion-point { + display: none; + } +} diff --git a/gb-src/packages/block-library/src/navigation-menu-item/index.js b/gb-src/packages/block-library/src/navigation-menu-item/index.js new file mode 100644 index 0000000000000..496ef92070428 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + title: __( 'Menu Item (Experimental)' ), + + parent: [ 'core/navigation-menu' ], + + icon: 'admin-links', + + description: __( 'Add a page, link, or other item to your Navigation Menu.' ), + + edit, + save, +}; + diff --git a/gb-src/packages/block-library/src/navigation-menu-item/menu-item-actions.js b/gb-src/packages/block-library/src/navigation-menu-item/menu-item-actions.js new file mode 100644 index 0000000000000..6e39036bab408 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/menu-item-actions.js @@ -0,0 +1,111 @@ +/** + * WordPress dependencies + */ +import { + MenuItem, + NavigableMenu, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +function MenuItemActions( { + destination, + moveLeft, + moveRight, + moveToEnd, + moveToStart, + onEditLableClicked, + remove, +} ) { + return ( + <NavigableMenu> + <MenuItem + icon="admin-links" + > + { destination } + </MenuItem> + <MenuItem + onClick={ onEditLableClicked } + icon="edit" + > + { __( 'Edit label text' ) } + </MenuItem> + <div className="wp-block-navigation-menu-item__separator" /> + <MenuItem + onClick={ moveToStart } + icon="arrow-up-alt2" + > + { __( 'Move to start' ) } + </MenuItem> + <MenuItem + onClick={ moveLeft } + icon="arrow-left-alt2" + > + { __( 'Move left' ) } + </MenuItem> + <MenuItem + onClick={ moveRight } + icon="arrow-right-alt2" + > + { __( 'Move right' ) } + </MenuItem> + <MenuItem + onClick={ moveToEnd } + icon="arrow-down-alt2" + > + { __( 'Move to end' ) } + </MenuItem> + <MenuItem + icon="arrow-left-alt2" + > + { __( 'Nest underneath…' ) } + </MenuItem> + <div className="navigation-menu-item__separator" /> + <MenuItem + onClick={ remove } + icon="trash" + > + { __( 'Remove from menu' ) } + </MenuItem> + </NavigableMenu> + ); +} + +export default compose( [ + withDispatch( ( dispatch, { clientId }, { select } ) => { + const { + getBlockOrder, + getBlockRootClientId, + } = select( 'core/block-editor' ); + const parentID = getBlockRootClientId( clientId ); + const { + moveBlocksDown, + moveBlocksUp, + moveBlockToPosition, + removeBlocks, + } = dispatch( 'core/block-editor' ); + return { + moveToStart() { + moveBlockToPosition( clientId, parentID, parentID, 0 ); + }, + moveRight() { + moveBlocksDown( clientId, parentID ); + }, + moveLeft() { + moveBlocksUp( clientId, parentID ); + }, + moveToEnd() { + moveBlockToPosition( + clientId, + parentID, + parentID, + getBlockOrder( parentID ).length - 1 + ); + }, + remove() { + removeBlocks( clientId ); + }, + }; + } ), +] )( MenuItemActions ); diff --git a/gb-src/packages/block-library/src/navigation-menu-item/save.js b/gb-src/packages/block-library/src/navigation-menu-item/save.js new file mode 100644 index 0000000000000..1a0549243fae8 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu-item/save.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { + InnerBlocks, +} from '@wordpress/block-editor'; + +export default function save() { + return ( + <InnerBlocks.Content /> + ); +} diff --git a/gb-src/packages/block-library/src/navigation-menu/edit.js b/gb-src/packages/block-library/src/navigation-menu/edit.js new file mode 100644 index 0000000000000..67025c2a2d04b --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/edit.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { + Fragment, +} from '@wordpress/element'; +import { + InnerBlocks, + InspectorControls, + BlockControls, +} from '@wordpress/block-editor'; +import { + CheckboxControl, + PanelBody, + Toolbar, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import useBlockNavigator from './use-block-navigator'; + +function NavigationMenu( { + attributes, + setAttributes, + clientId, +} ) { + const { navigatorToolbarButton, navigatorModal } = useBlockNavigator( clientId ); + + return ( + <Fragment> + <BlockControls> + <Toolbar> + { navigatorToolbarButton } + </Toolbar> + </BlockControls> + { navigatorModal } + <InspectorControls> + <PanelBody + title={ __( 'Menu Settings' ) } + > + <CheckboxControl + value={ attributes.automaticallyAdd } + onChange={ ( automaticallyAdd ) => { + setAttributes( { automaticallyAdd } ); + } } + label={ __( 'Automatically add new pages' ) } + help={ __( 'Automatically add new top level pages to this menu.' ) } + /> + </PanelBody> + </InspectorControls> + <div className="wp-block-navigation-menu"> + <InnerBlocks + allowedBlocks={ [ 'core/navigation-menu-item' ] } + renderAppender={ InnerBlocks.ButtonBlockAppender } + /> + </div> + </Fragment> + ); +} + +export default NavigationMenu; diff --git a/gb-src/packages/block-library/src/navigation-menu/editor.scss b/gb-src/packages/block-library/src/navigation-menu/editor.scss new file mode 100644 index 0000000000000..52ab897cf94d8 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/editor.scss @@ -0,0 +1,13 @@ +.wp-block-navigation-menu .block-editor-block-list__layout, +.wp-block-navigation-menu { + display: grid; + grid-auto-columns: min-content; + grid-auto-flow: column; + align-items: center; + white-space: nowrap; +} + +.wp-block-navigation-menu__inserter-content { + width: 350px; + padding: $grid-size-large; +} diff --git a/gb-src/packages/block-library/src/navigation-menu/index.js b/gb-src/packages/block-library/src/navigation-menu/index.js new file mode 100644 index 0000000000000..00ae521ecba52 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/index.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; + +export const name = 'core/navigation-menu'; + +export const settings = { + title: __( 'Navigation Menu (Experimental)' ), + + icon: 'menu', + + description: __( 'Add a navigation menu to your site.' ), + + keywords: [ __( 'menu' ), __( 'navigation' ), __( 'links' ) ], + + supports: { + align: [ 'wide', 'full' ], + anchor: true, + html: false, + inserter: true, + }, + + edit, + + save, + +}; diff --git a/gb-src/packages/block-library/src/navigation-menu/index.php b/gb-src/packages/block-library/src/navigation-menu/index.php new file mode 100644 index 0000000000000..73858feea053b --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/index.php @@ -0,0 +1,72 @@ +<?php +/** + * Server-side rendering of the `core/navigation-menu` block. + * + * @package gutenberg + */ + +/** + * Renders the `core/navigation-menu` block on server. + * + * @param array $attributes The block attributes. + * @param array $content The saved content. + * @param array $block The parsed block. + * + * @return string Returns the post content with the legacy widget added. + */ +function render_block_navigation_menu( $attributes, $content, $block ) { + return '<nav className="wp-block-navigation-menu">' . build_navigation_menu_html( $block ) . '</nav>'; +} + +/** + * Walks the inner block structure and returns an HTML list for it. + * + * @param array $block The block. + * + * @return string Returns an HTML list from innerBlocks. + */ +function build_navigation_menu_html( $block ) { + $html = ''; + foreach ( (array) $block['innerBlocks'] as $key => $menu_item ) { + $html .= '<li class="wp-block-navigation-menu-item"><a class="wp-block-navigation-menu-item"'; + if ( isset( $menu_item['attrs']['destination'] ) ) { + $html .= ' href="' . $menu_item['attrs']['destination'] . '"'; + } + if ( isset( $menu_item['attrs']['title'] ) ) { + $html .= ' title="' . $menu_item['attrs']['title'] . '"'; + } + $html .= '>'; + if ( isset( $menu_item['attrs']['label'] ) ) { + $html .= $menu_item['attrs']['label']; + } + $html .= '</a>'; + + if ( count( (array) $menu_item['innerBlocks'] ) > 0 ) { + $html .= build_navigation_menu_html( $menu_item ); + } + + $html .= '</li>'; + } + return '<ul>' . $html . '</ul>'; +} + +/** + * Register the navigation menu block. + */ +function register_block_core_navigation_menu() { + register_block_type( + 'core/navigation-menu', + array( + 'category' => 'layout', + 'attributes' => array( + 'automaticallyAdd' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'render_callback' => 'render_block_navigation_menu', + ) + ); +} + +add_action( 'init', 'register_block_core_navigation_menu' ); diff --git a/gb-src/packages/block-library/src/navigation-menu/save.js b/gb-src/packages/block-library/src/navigation-menu/save.js new file mode 100644 index 0000000000000..873432fe65954 --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/save.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save() { + return ( + <InnerBlocks.Content /> + ); +} diff --git a/gb-src/packages/block-library/src/navigation-menu/use-block-navigator.js b/gb-src/packages/block-library/src/navigation-menu/use-block-navigator.js new file mode 100644 index 0000000000000..dd924d3eb5f8b --- /dev/null +++ b/gb-src/packages/block-library/src/navigation-menu/use-block-navigator.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { + useState, +} from '@wordpress/element'; +import { + useSelect, + useDispatch, +} from '@wordpress/data'; +import { + __experimentalBlockNavigationList, +} from '@wordpress/block-editor'; +import { + IconButton, + SVG, + Path, + Modal, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +const NavigatorIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> + <Path d="M5 5H3v2h2V5zm3 8h11v-2H8v2zm9-8H6v2h11V5zM7 11H5v2h2v-2zm0 8h2v-2H7v2zm3-2v2h11v-2H10z" /> + </SVG> +); + +export default function useBlockNavigator( clientId ) { + const [ isNavigationListOpen, setIsNavigationListOpen ] = useState( false ); + + const { + block, + selectedBlockClientId, + } = useSelect( ( select ) => { + const { + getSelectedBlockClientId, + getBlock, + } = select( 'core/block-editor' ); + + return { + block: getBlock( clientId ), + selectedBlockClientId: getSelectedBlockClientId(), + }; + }, [ clientId ] ); + + const { + selectBlock, + } = useDispatch( 'core/block-editor' ); + + const navigatorToolbarButton = ( + <IconButton + className="components-toolbar__control" + label={ __( 'Open block navigator' ) } + onClick={ () => setIsNavigationListOpen( true ) } + icon={ NavigatorIcon } + /> + ); + + const navigatorModal = isNavigationListOpen && ( + <Modal + title={ __( 'Block Navigator' ) } + closeLabel={ __( 'Close' ) } + onRequestClose={ () => { + setIsNavigationListOpen( false ); + } } + > + <__experimentalBlockNavigationList + blocks={ [ block ] } + selectedBlockClientId={ selectedBlockClientId } + selectBlock={ selectBlock } + showNestedBlocks + /> + </Modal> + ); + + return { + navigatorToolbarButton, + navigatorModal, + }; +} diff --git a/gb-src/packages/block-library/src/nextpage/block.json b/gb-src/packages/block-library/src/nextpage/block.json new file mode 100644 index 0000000000000..2d3ccc904ed63 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/block.json @@ -0,0 +1,4 @@ +{ + "name": "core/nextpage", + "category": "layout" +} diff --git a/gb-src/packages/block-library/src/nextpage/edit.js b/gb-src/packages/block-library/src/nextpage/edit.js new file mode 100644 index 0000000000000..fb4d16ee52cb1 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/edit.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export default function NextPageEdit() { + return ( + <div className="wp-block-nextpage"> + <span>{ __( 'Page break' ) }</span> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/nextpage/edit.native.js b/gb-src/packages/block-library/src/nextpage/edit.native.js new file mode 100644 index 0000000000000..e3aa69b15e5e4 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/edit.native.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; +import Hr from 'react-native-hr'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import styles from './editor.scss'; + +export default function NextPageEdit( { attributes, isSelected, onFocus } ) { + const { customText = __( 'Page break' ) } = attributes; + const accessibilityTitle = attributes.customText || ''; + const accessibilityState = isSelected ? [ 'selected' ] : []; + + return ( + <View + accessible + accessibilityLabel={ + sprintf( + /* translators: accessibility text. %s: Page break text. */ + __( 'Page break block. %s' ), + accessibilityTitle + ) + } + accessibilityStates={ accessibilityState } + onAccessibilityTap={ onFocus } + > + <Hr text={ customText } + marginLeft={ 0 } + marginRight={ 0 } + textStyle={ styles[ 'block-library-nextpage__text' ] } + lineStyle={ styles[ 'block-library-nextpage__line' ] } /> + </View> + ); +} diff --git a/gb-src/packages/block-library/src/nextpage/editor.native.scss b/gb-src/packages/block-library/src/nextpage/editor.native.scss new file mode 100644 index 0000000000000..869851fdd37c6 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/editor.native.scss @@ -0,0 +1,12 @@ +// @format + +.block-library-nextpage__line { + background-color: $gray-lighten-20; + height: 2; +} + +.block-library-nextpage__text { + color: $gray; + text-decoration-style: solid; + text-transform: uppercase; +} diff --git a/gb-src/packages/block-library/src/nextpage/editor.scss b/gb-src/packages/block-library/src/nextpage/editor.scss new file mode 100644 index 0000000000000..99f3679e5ae60 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/editor.scss @@ -0,0 +1,36 @@ +.block-editor-block-list__block[data-type="core/nextpage"] { + max-width: 100%; + margin-top: $default-block-margin; + margin-bottom: $default-block-margin; +} + +.wp-block-nextpage { + display: block; + text-align: center; + white-space: nowrap; + + // Label + > span { + font-size: $default-font-size; + position: relative; + display: inline-block; + text-transform: uppercase; + font-weight: 600; + font-family: $default-font; + color: $dark-gray-300; + border-radius: 4px; + background: $white; + padding: 6px 8px; + height: $icon-button-size-small; + } + + // Dashed line + &::before { + content: ""; + position: absolute; + top: calc(50%); + left: 0; + right: 0; + border-top: 3px dashed $light-gray-700; + } +} diff --git a/gb-src/packages/block-library/src/nextpage/icon.js b/gb-src/packages/block-library/src/nextpage/icon.js new file mode 100644 index 0000000000000..a69aac47adf07 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><Path d="M0 0h24v24H0z" fill="none" /><Path d="M9 11h6v2H9zM2 11h5v2H2zM17 11h5v2h-5zM6 4h7v5h7V8l-6-6H6a2 2 0 0 0-2 2v5h2zM18 20H6v-5H4v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5h-2z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/nextpage/index.js b/gb-src/packages/block-library/src/nextpage/index.js new file mode 100644 index 0000000000000..9f39f881d83de --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/index.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Page Break' ), + description: __( 'Separate your content into a multi-page experience.' ), + icon, + keywords: [ __( 'next page' ), __( 'pagination' ) ], + supports: { + customClassName: false, + className: false, + html: false, + }, + example: {}, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/nextpage/save.js b/gb-src/packages/block-library/src/nextpage/save.js new file mode 100644 index 0000000000000..f86dd0dc6b4be --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/save.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save() { + return ( + <RawHTML> + { '<!--nextpage-->' } + </RawHTML> + ); +} diff --git a/gb-src/packages/block-library/src/nextpage/transforms.js b/gb-src/packages/block-library/src/nextpage/transforms.js new file mode 100644 index 0000000000000..e9aa6d2d9b199 --- /dev/null +++ b/gb-src/packages/block-library/src/nextpage/transforms.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'raw', + schema: { + 'wp-block': { attributes: [ 'data-block' ] }, + }, + isMatch: ( node ) => node.dataset && node.dataset.block === 'core/nextpage', + transform() { + return createBlock( 'core/nextpage', {} ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/paragraph/block.json b/gb-src/packages/block-library/src/paragraph/block.json new file mode 100644 index 0000000000000..4f1d668b7c4d5 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/block.json @@ -0,0 +1,44 @@ +{ + "name": "core/paragraph", + "category": "common", + "attributes": { + "align": { + "type": "string" + }, + "content": { + "type": "string", + "source": "html", + "selector": "p", + "default": "" + }, + "dropCap": { + "type": "boolean", + "default": false + }, + "placeholder": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "customTextColor": { + "type": "string" + }, + "backgroundColor": { + "type": "string" + }, + "customBackgroundColor": { + "type": "string" + }, + "fontSize": { + "type": "string" + }, + "customFontSize": { + "type": "number" + }, + "direction": { + "type": "string", + "enum": [ "ltr", "rtl" ] + } + } +} diff --git a/gb-src/packages/block-library/src/paragraph/deprecated.js b/gb-src/packages/block-library/src/paragraph/deprecated.js new file mode 100644 index 0000000000000..d7871b8854ca3 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/deprecated.js @@ -0,0 +1,217 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isFinite, omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + RawHTML, +} from '@wordpress/element'; +import { + getColorClassName, + getFontSizeClass, + RichText, +} from '@wordpress/block-editor'; + +const supports = { + className: false, +}; + +const blockAttributes = { + align: { + type: 'string', + }, + content: { + type: 'string', + source: 'html', + selector: 'p', + default: '', + }, + dropCap: { + type: 'boolean', + default: false, + }, + placeholder: { + type: 'string', + }, + textColor: { + type: 'string', + }, + customTextColor: { + type: 'string', + }, + backgroundColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + fontSize: { + type: 'string', + }, + customFontSize: { + type: 'number', + }, + direction: { + type: 'string', + enum: [ 'ltr', 'rtl' ], + }, +}; + +const deprecated = [ + { + supports, + attributes: blockAttributes, + save( { attributes } ) { + const { + align, + content, + dropCap, + backgroundColor, + textColor, + customBackgroundColor, + customTextColor, + fontSize, + customFontSize, + direction, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const fontSizeClass = getFontSizeClass( fontSize ); + + const className = classnames( { + 'has-text-color': textColor || customTextColor, + 'has-background': backgroundColor || customBackgroundColor, + 'has-drop-cap': dropCap, + [ fontSizeClass ]: fontSizeClass, + [ textClass ]: textClass, + [ backgroundClass ]: backgroundClass, + } ); + + const styles = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + fontSize: fontSizeClass ? undefined : customFontSize, + textAlign: align, + }; + + return ( + <RichText.Content + tagName="p" + style={ styles } + className={ className ? className : undefined } + value={ content } + dir={ direction } + /> + ); + }, + }, + { + supports, + attributes: { + ...blockAttributes, + width: { + type: 'string', + }, + }, + save( { attributes } ) { + const { + width, + align, + content, + dropCap, + backgroundColor, + textColor, + customBackgroundColor, + customTextColor, + fontSize, + customFontSize, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const fontSizeClass = fontSize && `is-${ fontSize }-text`; + + const className = classnames( { + [ `align${ width }` ]: width, + 'has-background': backgroundColor || customBackgroundColor, + 'has-drop-cap': dropCap, + [ fontSizeClass ]: fontSizeClass, + [ textClass ]: textClass, + [ backgroundClass ]: backgroundClass, + } ); + + const styles = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + fontSize: fontSizeClass ? undefined : customFontSize, + textAlign: align, + }; + + return ( + <RichText.Content + tagName="p" + style={ styles } + className={ className ? className : undefined } + value={ content } + /> + ); + }, + }, + { + supports, + attributes: omit( { + ...blockAttributes, + fontSize: { + type: 'number', + }, + }, 'customFontSize', 'customTextColor', 'customBackgroundColor' ), + save( { attributes } ) { + const { width, align, content, dropCap, backgroundColor, textColor, fontSize } = attributes; + const className = classnames( { + [ `align${ width }` ]: width, + 'has-background': backgroundColor, + 'has-drop-cap': dropCap, + } ); + const styles = { + backgroundColor, + color: textColor, + fontSize, + textAlign: align, + }; + + return <p style={ styles } className={ className ? className : undefined }>{ content }</p>; + }, + migrate( attributes ) { + return omit( { + ...attributes, + customFontSize: isFinite( attributes.fontSize ) ? attributes.fontSize : undefined, + customTextColor: attributes.textColor && '#' === attributes.textColor[ 0 ] ? attributes.textColor : undefined, + customBackgroundColor: attributes.backgroundColor && '#' === attributes.backgroundColor[ 0 ] ? attributes.backgroundColor : undefined, + }, [ 'fontSize', 'textColor', 'backgroundColor' ] ); + }, + }, + { + supports, + attributes: { + ...blockAttributes, + content: { + type: 'string', + source: 'html', + default: '', + }, + }, + save( { attributes } ) { + return <RawHTML>{ attributes.content }</RawHTML>; + }, + migrate( attributes ) { + return attributes; + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/paragraph/edit.js b/gb-src/packages/block-library/src/paragraph/edit.js new file mode 100644 index 0000000000000..0d41efd5c2683 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/edit.js @@ -0,0 +1,218 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { + PanelBody, + ToggleControl, + Toolbar, + withFallbackStyles, +} from '@wordpress/components'; +import { + withColors, + AlignmentToolbar, + BlockControls, + ContrastChecker, + FontSizePicker, + InspectorControls, + PanelColorSettings, + RichText, + withFontSizes, +} from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +const { getComputedStyle } = window; + +const name = 'core/paragraph'; + +const applyFallbackStyles = withFallbackStyles( ( node, ownProps ) => { + const { textColor, backgroundColor, fontSize, customFontSize } = ownProps.attributes; + const editableNode = node.querySelector( '[contenteditable="true"]' ); + //verify if editableNode is available, before using getComputedStyle. + const computedStyles = editableNode ? getComputedStyle( editableNode ) : null; + return { + fallbackBackgroundColor: backgroundColor || ! computedStyles ? undefined : computedStyles.backgroundColor, + fallbackTextColor: textColor || ! computedStyles ? undefined : computedStyles.color, + fallbackFontSize: fontSize || customFontSize || ! computedStyles ? undefined : parseInt( computedStyles.fontSize ) || undefined, + }; +} ); + +class ParagraphBlock extends Component { + constructor() { + super( ...arguments ); + + this.toggleDropCap = this.toggleDropCap.bind( this ); + } + + toggleDropCap() { + const { attributes, setAttributes } = this.props; + setAttributes( { dropCap: ! attributes.dropCap } ); + } + + getDropCapHelp( checked ) { + return checked ? __( 'Showing large initial letter.' ) : __( 'Toggle to show a large initial letter.' ); + } + + render() { + const { + attributes, + setAttributes, + mergeBlocks, + onReplace, + className, + backgroundColor, + textColor, + setBackgroundColor, + setTextColor, + fallbackBackgroundColor, + fallbackTextColor, + fallbackFontSize, + fontSize, + setFontSize, + isRTL, + } = this.props; + + const { + align, + content, + dropCap, + placeholder, + direction, + } = attributes; + + return ( + <> + <BlockControls> + <AlignmentToolbar + value={ align } + onChange={ ( nextAlign ) => { + setAttributes( { align: nextAlign } ); + } } + /> + { isRTL && ( + <Toolbar + controls={ [ + { + icon: 'editor-ltr', + title: _x( 'Left to right', 'editor button' ), + isActive: direction === 'ltr', + onClick() { + const nextDirection = direction === 'ltr' ? undefined : 'ltr'; + setAttributes( { + direction: nextDirection, + } ); + }, + }, + ] } + /> + ) } + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Text Settings' ) } className="blocks-font-size"> + <FontSizePicker + fallbackFontSize={ fallbackFontSize } + value={ fontSize.size } + onChange={ setFontSize } + /> + <ToggleControl + label={ __( 'Drop Cap' ) } + checked={ !! dropCap } + onChange={ this.toggleDropCap } + help={ this.getDropCapHelp } + /> + </PanelBody> + <PanelColorSettings + title={ __( 'Color Settings' ) } + initialOpen={ false } + colorSettings={ [ + { + value: backgroundColor.color, + onChange: setBackgroundColor, + label: __( 'Background Color' ), + }, + { + value: textColor.color, + onChange: setTextColor, + label: __( 'Text Color' ), + }, + ] } + > + <ContrastChecker + { ...{ + textColor: textColor.color, + backgroundColor: backgroundColor.color, + fallbackTextColor, + fallbackBackgroundColor, + } } + fontSize={ fontSize.size } + /> + </PanelColorSettings> + </InspectorControls> + <RichText + identifier="content" + tagName="p" + className={ classnames( 'wp-block-paragraph', className, { + 'has-text-color': textColor.color, + 'has-background': backgroundColor.color, + 'has-drop-cap': dropCap, + [ `has-text-align-${ align }` ]: align, + [ backgroundColor.class ]: backgroundColor.class, + [ textColor.class ]: textColor.class, + [ fontSize.class ]: fontSize.class, + } ) } + style={ { + backgroundColor: backgroundColor.color, + color: textColor.color, + fontSize: fontSize.size ? fontSize.size + 'px' : undefined, + direction, + } } + value={ content } + onChange={ ( nextContent ) => { + setAttributes( { + content: nextContent, + } ); + } } + onSplit={ ( value ) => { + if ( ! value ) { + return createBlock( name ); + } + + return createBlock( name, { + ...attributes, + content: value, + } ); + } } + onMerge={ mergeBlocks } + onReplace={ onReplace } + onRemove={ onReplace ? () => onReplace( [] ) : undefined } + aria-label={ content ? __( 'Paragraph block' ) : __( 'Empty block; start writing or type forward slash to choose a block' ) } + placeholder={ placeholder || __( 'Start writing or type / to choose a block' ) } + __unstableEmbedURLOnPaste + /> + </> + ); + } +} + +const ParagraphEdit = compose( [ + withColors( 'backgroundColor', { textColor: 'color' } ), + withFontSizes( 'fontSize' ), + applyFallbackStyles, + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + + return { + isRTL: getSettings().isRTL, + }; + } ), +] )( ParagraphBlock ); + +export default ParagraphEdit; diff --git a/gb-src/packages/block-library/src/paragraph/edit.native.js b/gb-src/packages/block-library/src/paragraph/edit.native.js new file mode 100644 index 0000000000000..6dbcaf8a1459d --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/edit.native.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { createBlock } from '@wordpress/blocks'; +import { RichText } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ + +const name = 'core/paragraph'; + +class ParagraphEdit extends Component { + constructor( props ) { + super( props ); + this.onReplace = this.onReplace.bind( this ); + } + + onReplace( blocks ) { + const { attributes, onReplace } = this.props; + onReplace( blocks.map( ( block, index ) => ( + index === 0 && block.name === name ? + { ...block, + attributes: { + ...attributes, + ...block.attributes, + }, + } : + block + ) ) ); + } + + render() { + const { + attributes, + setAttributes, + mergeBlocks, + onReplace, + style, + } = this.props; + + const { + placeholder, + content, + } = attributes; + + return ( + <View> + <RichText + identifier="content" + tagName="p" + value={ content } + deleteEnter={ true } + style={ style } + onChange={ ( nextContent ) => { + setAttributes( { + content: nextContent, + } ); + } } + onSplit={ ( value ) => { + if ( ! value ) { + return createBlock( name ); + } + + return createBlock( name, { + ...attributes, + content: value, + } ); + } } + onMerge={ mergeBlocks } + onReplace={ onReplace } + onRemove={ onReplace ? () => onReplace( [] ) : undefined } + placeholder={ placeholder || __( 'Start writing…' ) } + /> + </View> + ); + } +} + +export default ParagraphEdit; diff --git a/gb-src/packages/block-library/src/paragraph/editor.scss b/gb-src/packages/block-library/src/paragraph/editor.scss new file mode 100644 index 0000000000000..c5435f7c9afaf --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/editor.scss @@ -0,0 +1,17 @@ +// Specific to the empty paragraph placeholder: +// when shown on mobile and in nested contexts, one or more icons show up on the right. +// This padding makes sure it doesn't overlap text. +.block-editor-rich-text__editable.wp-block-paragraph:not(.is-selected) [data-rich-text-placeholder]::after { + display: inline-block; + padding-right: $icon-button-size * 3; + + // In nested contexts only one icon shows up. + .wp-block .wp-block & { + padding-right: $icon-button-size; + } +} + +.block-editor-block-list__block[data-type="core/paragraph"] p { + min-height: $empty-paragraph-height / 2; + line-height: $editor-line-height; +} diff --git a/gb-src/packages/block-library/src/paragraph/icon.js b/gb-src/packages/block-library/src/paragraph/icon.js new file mode 100644 index 0000000000000..d8f6a46df59bc --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M11 5v7H9.5C7.6 12 6 10.4 6 8.5S7.6 5 9.5 5H11m8-2H9.5C6.5 3 4 5.5 4 8.5S6.5 14 9.5 14H11v7h2V5h2v16h2V5h2V3z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/paragraph/index.js b/gb-src/packages/block-library/src/paragraph/index.js new file mode 100644 index 0000000000000..63ff90b908fa0 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/index.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Paragraph' ), + description: __( 'Start with the building block of all narrative.' ), + icon, + keywords: [ __( 'text' ) ], + example: { + attributes: { + content: __( 'In a village of La Mancha, the name of which I have no desire to call to mind, there lived not long since one of those gentlemen that keep a lance in the lance-rack, an old buckler, a lean hack, and a greyhound for coursing.' ), + customFontSize: 28, + dropCap: true, + }, + }, + supports: { + className: false, + }, + transforms, + deprecated, + merge( attributes, attributesToMerge ) { + return { + content: ( attributes.content || '' ) + ( attributesToMerge.content || '' ), + }; + }, + getEditWrapperProps( attributes ) { + const { width } = attributes; + if ( [ 'wide', 'full', 'left', 'right' ].indexOf( width ) !== -1 ) { + return { 'data-align': width }; + } + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/paragraph/index.native.js b/gb-src/packages/block-library/src/paragraph/index.native.js new file mode 100644 index 0000000000000..4de4948f25180 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/index.native.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { create } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + __experimentalGetAccessibilityLabel( attributes ) { + const { content } = attributes; + + const plainTextContent = ( html ) => create( { html } ).text || ''; + + return isEmpty( content ) ? __( 'Empty' ) : plainTextContent( content ); + }, +}; diff --git a/gb-src/packages/block-library/src/paragraph/save.js b/gb-src/packages/block-library/src/paragraph/save.js new file mode 100644 index 0000000000000..b2c1f47a4f32a --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/save.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, + getFontSizeClass, + RichText, +} from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + align, + content, + dropCap, + backgroundColor, + textColor, + customBackgroundColor, + customTextColor, + fontSize, + customFontSize, + direction, + } = attributes; + + const textClass = getColorClassName( 'color', textColor ); + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const fontSizeClass = getFontSizeClass( fontSize ); + + const className = classnames( { + 'has-text-color': textColor || customTextColor, + 'has-background': backgroundColor || customBackgroundColor, + 'has-drop-cap': dropCap, + [ `has-text-align-${ align }` ]: align, + [ fontSizeClass ]: fontSizeClass, + [ textClass ]: textClass, + [ backgroundClass ]: backgroundClass, + } ); + + const styles = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + color: textClass ? undefined : customTextColor, + fontSize: fontSizeClass ? undefined : customFontSize, + }; + + return ( + <RichText.Content + tagName="p" + style={ styles } + className={ className ? className : undefined } + value={ content } + dir={ direction } + /> + ); +} diff --git a/gb-src/packages/block-library/src/paragraph/style.native.scss b/gb-src/packages/block-library/src/paragraph/style.native.scss new file mode 100644 index 0000000000000..87d8ccedc8dc8 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/style.native.scss @@ -0,0 +1,4 @@ + +.blockText { + min-height: $min-height-paragraph; +} diff --git a/gb-src/packages/block-library/src/paragraph/style.scss b/gb-src/packages/block-library/src/paragraph/style.scss new file mode 100644 index 0000000000000..62f47a757a7bc --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/style.scss @@ -0,0 +1,44 @@ +.is-small-text { + font-size: 14px; +} + +.is-regular-text { + font-size: 16px; +} + +.is-large-text { + font-size: 36px; +} + +.is-larger-text { + font-size: 48px; +} + +// Don't show the drop cap when editing the paragraph's content. It causes a +// number of bugs in combination with `contenteditable` fields. The caret +// cannot be set around it, caret position calculation fails in Chrome, and +// typing at the end of the paragraph doesn't work. +.has-drop-cap:not(:focus)::first-letter { + float: left; + font-size: 8.4em; + line-height: 0.68; + font-weight: 100; + margin: 0.05em 0.1em 0 0; + text-transform: uppercase; + font-style: normal; +} + +.has-drop-cap:not(:focus)::after { + content: ""; + display: table; + clear: both; + padding-top: $block-padding; +} + +p.has-background { + padding: $block-bg-padding--v $block-bg-padding--h; +} + +p.has-text-color a { + color: inherit; +} diff --git a/gb-src/packages/block-library/src/paragraph/test/edit.native.js b/gb-src/packages/block-library/src/paragraph/test/edit.native.js new file mode 100644 index 0000000000000..3a7fea4e28e35 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/test/edit.native.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Paragraph from '../edit'; + +/** + * WordPress dependencies + */ +jest.mock( '@wordpress/blocks' ); + +const getTestComponentWithContent = ( content ) => { + return shallow( + <Paragraph + attributes={ { content } } + setAttributes={ jest.fn() } + onReplace={ jest.fn() } + insertBlocksAfter={ jest.fn() } + /> + ); +}; + +describe( 'Paragraph block', () => { + it( 'renders without crashing', () => { + const component = getTestComponentWithContent( '' ); + expect( component.exists() ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/paragraph/transforms.js b/gb-src/packages/block-library/src/paragraph/transforms.js new file mode 100644 index 0000000000000..51aa06292ef86 --- /dev/null +++ b/gb-src/packages/block-library/src/paragraph/transforms.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { getPhrasingContentSchema } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'raw', + // Paragraph is a fallback and should be matched last. + priority: 20, + selector: 'p', + schema: { + p: { + children: getPhrasingContentSchema(), + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/preformatted/block.json b/gb-src/packages/block-library/src/preformatted/block.json new file mode 100644 index 0000000000000..fadcbd4a860ed --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/block.json @@ -0,0 +1,12 @@ +{ + "name": "core/preformatted", + "category": "formatting", + "attributes": { + "content": { + "type": "string", + "source": "html", + "selector": "pre", + "default": "" + } + } +} diff --git a/gb-src/packages/block-library/src/preformatted/edit.js b/gb-src/packages/block-library/src/preformatted/edit.js new file mode 100644 index 0000000000000..7fb65dfdf40a3 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/edit.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { RichText } from '@wordpress/block-editor'; + +export default function PreformattedEdit( { attributes, mergeBlocks, setAttributes, className } ) { + const { content } = attributes; + + return ( + <RichText + tagName="pre" + // Ensure line breaks are normalised to HTML. + value={ content.replace( /\n/g, '<br>' ) } + onChange={ ( nextContent ) => { + setAttributes( { + // Ensure line breaks are normalised to characters. This + // saves space, is easier to read, and ensures display + // filters work correctly. + content: nextContent.replace( /<br ?\/?>/g, '\n' ), + } ); + } } + placeholder={ __( 'Write preformatted text…' ) } + wrapperClassName={ className } + onMerge={ mergeBlocks } + /> + ); +} diff --git a/gb-src/packages/block-library/src/preformatted/editor.scss b/gb-src/packages/block-library/src/preformatted/editor.scss new file mode 100644 index 0000000000000..a5e2c6d240013 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/editor.scss @@ -0,0 +1,5 @@ +.wp-block-preformatted { + pre { + white-space: pre-wrap; + } +} diff --git a/gb-src/packages/block-library/src/preformatted/icon.js b/gb-src/packages/block-library/src/preformatted/icon.js new file mode 100644 index 0000000000000..2ece9106140a8 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, Rect, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="M20,4H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V6C22,4.9,21.1,4,20,4z M20,18H4V6h16V18z" /><Rect x="6" y="10" width="2" height="2" /><Rect x="6" y="14" width="8" height="2" /><Rect x="16" y="14" width="2" height="2" /><Rect x="10" y="10" width="8" height="2" /></SVG> +); diff --git a/gb-src/packages/block-library/src/preformatted/index.js b/gb-src/packages/block-library/src/preformatted/index.js new file mode 100644 index 0000000000000..8a0bed6772734 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/index.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Preformatted' ), + description: __( 'Add text that respects your spacing and tabs, and also allows styling.' ), + icon, + example: { + attributes: { + content: __( 'EXT. XANADU - FAINT DAWN - 1940 (MINIATURE)' ) + '\n' + + __( 'Window, very small in the distance, illuminated.' ) + '\n' + + __( 'All around this is an almost totally black screen. Now, as the camera moves slowly towards the window which is almost a postage stamp in the frame, other forms appear;' ), + }, + }, + transforms, + edit, + save, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + attributesToMerge.content, + }; + }, +}; diff --git a/gb-src/packages/block-library/src/preformatted/save.js b/gb-src/packages/block-library/src/preformatted/save.js new file mode 100644 index 0000000000000..f11dabb3f93e6 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/save.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { content } = attributes; + + return <RichText.Content tagName="pre" value={ content } />; +} diff --git a/gb-src/packages/block-library/src/preformatted/theme.scss b/gb-src/packages/block-library/src/preformatted/theme.scss new file mode 100644 index 0000000000000..e0a221912e620 --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/theme.scss @@ -0,0 +1,12 @@ +.wp-block-preformatted { + pre { + font-family: $editor-html-font; + color: $dark-gray-800; + + /* Fonts smaller than 16px causes mobile safari to zoom. */ + font-size: $mobile-text-min-font-size; + @include break-small { + font-size: $text-editor-font-size; + } + } +} diff --git a/gb-src/packages/block-library/src/preformatted/transforms.js b/gb-src/packages/block-library/src/preformatted/transforms.js new file mode 100644 index 0000000000000..f7d736ef5770a --- /dev/null +++ b/gb-src/packages/block-library/src/preformatted/transforms.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/code', 'core/paragraph' ], + transform: ( { content } ) => + createBlock( 'core/preformatted', { + content, + } ), + }, + { + type: 'raw', + isMatch: ( node ) => ( + node.nodeName === 'PRE' && + ! ( + node.children.length === 1 && + node.firstChild.nodeName === 'CODE' + ) + ), + schema: { + pre: { + children: getPhrasingContentSchema(), + }, + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( attributes ) => + createBlock( 'core/paragraph', attributes ), + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/pullquote/block.json b/gb-src/packages/block-library/src/pullquote/block.json new file mode 100644 index 0000000000000..59784cbf567e8 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/block.json @@ -0,0 +1,30 @@ +{ + "name": "core/pullquote", + "category": "formatting", + "attributes": { + "value": { + "type": "string", + "source": "html", + "selector": "blockquote", + "multiline": "p" + }, + "citation": { + "type": "string", + "source": "html", + "selector": "cite", + "default": "" + }, + "mainColor": { + "type": "string" + }, + "customMainColor": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "customTextColor": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/pullquote/deprecated.js b/gb-src/packages/block-library/src/pullquote/deprecated.js new file mode 100644 index 0000000000000..150bd1eb615c9 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/deprecated.js @@ -0,0 +1,243 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { get, includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, + getColorObjectByAttributeValues, + RichText, +} from '@wordpress/block-editor'; +import { + select, +} from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { SOLID_COLOR_CLASS } from './shared'; + +const blockAttributes = { + value: { + type: 'string', + source: 'html', + selector: 'blockquote', + multiline: 'p', + }, + citation: { + type: 'string', + source: 'html', + selector: 'cite', + default: '', + }, + mainColor: { + type: 'string', + }, + customMainColor: { + type: 'string', + }, + textColor: { + type: 'string', + }, + customTextColor: { + type: 'string', + }, +}; + +function parseBorderColor( styleString ) { + if ( ! styleString ) { + return; + } + const matches = styleString.match( /border-color:([^;]+)[;]?/ ); + if ( matches && matches[ 1 ] ) { + return matches[ 1 ]; + } +} + +const deprecated = [ + { + attributes: { + ...blockAttributes, + // figureStyle is an attribute that never existed. + // We are using it as a way to access the styles previously applied to the figure. + figureStyle: { + source: 'attribute', + selector: 'figure', + attribute: 'style', + }, + }, + save( { attributes } ) { + const { + mainColor, + customMainColor, + textColor, + customTextColor, + value, + citation, + className, + figureStyle, + } = attributes; + + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + + let figureClasses, figureStyles; + + // Is solid color style + if ( isSolidColorStyle ) { + const backgroundClass = getColorClassName( 'background-color', mainColor ); + + figureClasses = classnames( { + 'has-background': ( backgroundClass || customMainColor ), + [ backgroundClass ]: backgroundClass, + } ); + + figureStyles = { + backgroundColor: backgroundClass ? undefined : customMainColor, + }; + // Is normal style and a custom color is being used ( we can set a style directly with its value) + } else if ( customMainColor ) { + figureStyles = { + borderColor: customMainColor, + }; + // If normal style and a named color are being used, we need to retrieve the color value to set the style, + // as there is no expectation that themes create classes that set border colors. + } else if ( mainColor ) { + // Previously here we queried the color settings to know the color value + // of a named color. This made the save function impure and the block was refactored, + // because meanwhile a change in the editor made it impossible to query color settings in the save function. + // Here instead of querying the color settings to know the color value, we retrieve the value + // directly from the style previously serialized. + const borderColor = parseBorderColor( figureStyle ); + figureStyles = { + borderColor, + }; + } + + const blockquoteTextColorClass = getColorClassName( 'color', textColor ); + const blockquoteClasses = ( textColor || customTextColor ) && classnames( 'has-text-color', { + [ blockquoteTextColorClass ]: blockquoteTextColorClass, + } ); + + const blockquoteStyles = blockquoteTextColorClass ? undefined : { color: customTextColor }; + + return ( + <figure className={ figureClasses } style={ figureStyles }> + <blockquote className={ blockquoteClasses } style={ blockquoteStyles } > + <RichText.Content value={ value } multiline /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + </figure> + ); + }, + migrate( { className, figureStyle, mainColor, ...attributes } ) { + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + // If is the default style, and a main color is set, + // migrate the main color value into a custom color. + // The custom color value is retrived by parsing the figure styles. + if ( ! isSolidColorStyle && mainColor && figureStyle ) { + const borderColor = parseBorderColor( figureStyle ); + if ( borderColor ) { + return { + ...attributes, + className, + customMainColor: borderColor, + }; + } + } + return { + className, + mainColor, + ...attributes, + }; + }, + }, + { + attributes: blockAttributes, + save( { attributes } ) { + const { mainColor, customMainColor, textColor, customTextColor, value, citation, className } = attributes; + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + + let figureClass, figureStyles; + // Is solid color style + if ( isSolidColorStyle ) { + figureClass = getColorClassName( 'background-color', mainColor ); + if ( ! figureClass ) { + figureStyles = { + backgroundColor: customMainColor, + }; + } + // Is normal style and a custom color is being used ( we can set a style directly with its value) + } else if ( customMainColor ) { + figureStyles = { + borderColor: customMainColor, + }; + // Is normal style and a named color is being used, we need to retrieve the color value to set the style, + // as there is no expectation that themes create classes that set border colors. + } else if ( mainColor ) { + const colors = get( select( 'core/block-editor' ).getSettings(), [ 'colors' ], [] ); + const colorObject = getColorObjectByAttributeValues( colors, mainColor ); + figureStyles = { + borderColor: colorObject.color, + }; + } + + const blockquoteTextColorClass = getColorClassName( 'color', textColor ); + const blockquoteClasses = textColor || customTextColor ? classnames( 'has-text-color', { + [ blockquoteTextColorClass ]: blockquoteTextColorClass, + } ) : undefined; + const blockquoteStyle = blockquoteTextColorClass ? undefined : { color: customTextColor }; + return ( + <figure className={ figureClass } style={ figureStyles }> + <blockquote className={ blockquoteClasses } style={ blockquoteStyle } > + <RichText.Content value={ value } multiline /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + </figure> + ); + }, + }, + { + attributes: { + ...blockAttributes, + }, + save( { attributes } ) { + const { value, citation } = attributes; + return ( + <blockquote> + <RichText.Content value={ value } multiline /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + ); + }, + }, { + attributes: { + ...blockAttributes, + citation: { + type: 'string', + source: 'html', + selector: 'footer', + }, + align: { + type: 'string', + default: 'none', + }, + }, + + save( { attributes } ) { + const { value, citation, align } = attributes; + + return ( + <blockquote className={ `align${ align }` }> + <RichText.Content value={ value } multiline /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="footer" value={ citation } /> } + </blockquote> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/pullquote/edit.js b/gb-src/packages/block-library/src/pullquote/edit.js new file mode 100644 index 0000000000000..509c95c39e26a --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/edit.js @@ -0,0 +1,178 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { + RichText, + ContrastChecker, + InspectorControls, + withColors, + PanelColorSettings, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { SOLID_COLOR_CLASS } from './shared'; + +class PullQuoteEdit extends Component { + constructor( props ) { + super( props ); + + this.wasTextColorAutomaticallyComputed = false; + this.pullQuoteMainColorSetter = this.pullQuoteMainColorSetter.bind( this ); + this.pullQuoteTextColorSetter = this.pullQuoteTextColorSetter.bind( this ); + } + + pullQuoteMainColorSetter( colorValue ) { + const { colorUtils, textColor, setAttributes, setTextColor, setMainColor, className } = this.props; + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + const needTextColor = ! textColor.color || this.wasTextColorAutomaticallyComputed; + const shouldSetTextColor = isSolidColorStyle && needTextColor && colorValue; + + if ( isSolidColorStyle ) { + // If we use the solid color style, set the color using the normal mechanism. + setMainColor( colorValue ); + } else { + // If we use the default style, set the color as a custom color to force the usage of an inline style. + // Default style uses a border color for which classes are not available. + setAttributes( { customMainColor: colorValue } ); + } + + if ( shouldSetTextColor ) { + this.wasTextColorAutomaticallyComputed = true; + setTextColor( colorUtils.getMostReadableColor( colorValue ) ); + } + } + + pullQuoteTextColorSetter( colorValue ) { + const { setTextColor } = this.props; + setTextColor( colorValue ); + this.wasTextColorAutomaticallyComputed = false; + } + + componentDidUpdate( prevProps ) { + const { + attributes, + className, + mainColor, + setAttributes, + } = this.props; + // If the block includes a named color and we switched from the + // solid color style to the default style. + if ( attributes.mainColor && ! includes( className, SOLID_COLOR_CLASS ) && includes( prevProps.className, SOLID_COLOR_CLASS ) ) { + // Remove the named color, and set the color as a custom color. + // This is done because named colors use classes, in the default style we use a border color, + // and themes don't set classes for border colors. + setAttributes( { mainColor: undefined, customMainColor: mainColor.color } ); + } + } + + render() { + const { + attributes, + mainColor, + textColor, + setAttributes, + isSelected, + className, + } = this.props; + + const { value, citation } = attributes; + + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + const figureStyles = isSolidColorStyle ? + { backgroundColor: mainColor.color } : + { borderColor: mainColor.color }; + + const figureClasses = classnames( className, { + 'has-background': isSolidColorStyle && mainColor.color, + [ mainColor.class ]: isSolidColorStyle && mainColor.class, + } ); + + const blockquoteStyles = { + color: textColor.color, + }; + + const blockquoteClasses = textColor.color && classnames( + 'has-text-color', + { [ textColor.class ]: textColor.class } + ); + + return ( + <> + <figure style={ figureStyles } className={ figureClasses }> + <blockquote style={ blockquoteStyles } className={ blockquoteClasses }> + <RichText + multiline + value={ value } + onChange={ + ( nextValue ) => setAttributes( { + value: nextValue, + } ) + } + placeholder={ + // translators: placeholder text used for the quote + __( 'Write quote…' ) + } + wrapperClassName="block-library-pullquote__content" + /> + { ( ! RichText.isEmpty( citation ) || isSelected ) && ( + <RichText + value={ citation } + placeholder={ + // translators: placeholder text used for the citation + __( 'Write citation…' ) + } + onChange={ + ( nextCitation ) => setAttributes( { + citation: nextCitation, + } ) + } + className="wp-block-pullquote__citation" + /> + ) } + </blockquote> + </figure> + <InspectorControls> + <PanelColorSettings + title={ __( 'Color Settings' ) } + colorSettings={ [ + { + value: mainColor.color, + onChange: this.pullQuoteMainColorSetter, + label: __( 'Main Color' ), + }, + { + value: textColor.color, + onChange: this.pullQuoteTextColorSetter, + label: __( 'Text Color' ), + }, + ] } + > + { isSolidColorStyle && ( + <ContrastChecker + { ...{ + textColor: textColor.color, + backgroundColor: mainColor.color, + } } + isLargeText={ false } + /> + ) } + </PanelColorSettings> + </InspectorControls> + </> + ); + } +} + +export default withColors( { mainColor: 'background-color', textColor: 'color' } )( + PullQuoteEdit +); diff --git a/gb-src/packages/block-library/src/pullquote/editor.scss b/gb-src/packages/block-library/src/pullquote/editor.scss new file mode 100644 index 0000000000000..3cb64db5b49ec --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/editor.scss @@ -0,0 +1,33 @@ +.block-editor-block-list__block[data-type="core/pullquote"] { + &[data-align="left"], + &[data-align="right"] { + & .block-editor-rich-text p { + font-size: 20px; + } + } +} + +.wp-block-pullquote { + & blockquote > .block-editor-rich-text p { + font-size: 28px; + line-height: 1.6; + } +} + +.wp-block-pullquote.is-style-solid-color { + margin-left: 0; + margin-right: 0; + + & blockquote > .block-editor-rich-text p { + font-size: 32px; + } + + .wp-block-pullquote__citation { + text-transform: none; + font-style: normal; + } +} + +.wp-block-pullquote .wp-block-pullquote__citation { + color: inherit; +} diff --git a/gb-src/packages/block-library/src/pullquote/icon.js b/gb-src/packages/block-library/src/pullquote/icon.js new file mode 100644 index 0000000000000..f8200535b7961 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, Polygon, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Polygon points="21 18 2 18 2 20 21 20" /><Path d="m19 10v4h-15v-4h15m1-2h-17c-0.55 0-1 0.45-1 1v6c0 0.55 0.45 1 1 1h17c0.55 0 1-0.45 1-1v-6c0-0.55-0.45-1-1-1z" /><Polygon points="21 4 2 4 2 6 21 6" /></SVG> +); diff --git a/gb-src/packages/block-library/src/pullquote/index.js b/gb-src/packages/block-library/src/pullquote/index.js new file mode 100644 index 0000000000000..c3dfcdeed9b95 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/index.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { SOLID_COLOR_STYLE_NAME } from './shared'; +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Pullquote' ), + description: __( 'Give special visual emphasis to a quote from your text.' ), + icon, + example: { + attributes: { + value: '<p>' + __( 'One of the hardest things to do in technology is disrupt yourself.' ) + '</p>', + citation: 'Matt Mullenweg', + }, + }, + styles: [ + { name: 'default', label: _x( 'Default', 'block style' ), isDefault: true }, + { name: SOLID_COLOR_STYLE_NAME, label: __( 'Solid Color' ) }, + ], + supports: { + align: [ 'left', 'right', 'wide', 'full' ], + }, + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/pullquote/save.js b/gb-src/packages/block-library/src/pullquote/save.js new file mode 100644 index 0000000000000..c2f9833046fa0 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/save.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, + RichText, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { SOLID_COLOR_CLASS } from './shared'; + +export default function save( { attributes } ) { + const { + mainColor, + customMainColor, + textColor, + customTextColor, + value, + citation, + className, + } = attributes; + + const isSolidColorStyle = includes( className, SOLID_COLOR_CLASS ); + + let figureClasses, figureStyles; + + // Is solid color style + if ( isSolidColorStyle ) { + const backgroundClass = getColorClassName( 'background-color', mainColor ); + + figureClasses = classnames( { + 'has-background': ( backgroundClass || customMainColor ), + [ backgroundClass ]: backgroundClass, + } ); + + figureStyles = { + backgroundColor: backgroundClass ? undefined : customMainColor, + }; + // Is normal style and a custom color is being used ( we can set a style directly with its value) + } else if ( customMainColor ) { + figureStyles = { + borderColor: customMainColor, + }; + } + + const blockquoteTextColorClass = getColorClassName( 'color', textColor ); + const blockquoteClasses = ( textColor || customTextColor ) && classnames( 'has-text-color', { + [ blockquoteTextColorClass ]: blockquoteTextColorClass, + } ); + + const blockquoteStyles = blockquoteTextColorClass ? undefined : { color: customTextColor }; + + return ( + <figure className={ figureClasses } style={ figureStyles }> + <blockquote className={ blockquoteClasses } style={ blockquoteStyles } > + <RichText.Content value={ value } multiline /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/pullquote/shared.js b/gb-src/packages/block-library/src/pullquote/shared.js new file mode 100644 index 0000000000000..f4a58bc2baed2 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/shared.js @@ -0,0 +1,2 @@ +export const SOLID_COLOR_STYLE_NAME = 'solid-color'; +export const SOLID_COLOR_CLASS = `is-style-${ SOLID_COLOR_STYLE_NAME }`; diff --git a/gb-src/packages/block-library/src/pullquote/style.scss b/gb-src/packages/block-library/src/pullquote/style.scss new file mode 100644 index 0000000000000..4394548bfd2af --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/style.scss @@ -0,0 +1,58 @@ +.wp-block-pullquote { + padding: 3em 0; + margin-left: 0; + margin-right: 0; + text-align: center; + + &.alignleft, + &.alignright { + max-width: $content-width / 2; + + p { + font-size: 20px; + } + } + + p { + font-size: 28px; + line-height: 1.6; + } + + cite, + footer { + position: relative; + } + .has-text-color a { + color: inherit; + } +} + +.wp-block-pullquote:not(.is-style-solid-color) { + background: none; +} + +.wp-block-pullquote.is-style-solid-color { + border: none; + blockquote { + margin-left: auto; + margin-right: auto; + text-align: left; + + max-width: 60%; + + p { + margin-top: 0; + margin-bottom: 0; + font-size: 32px; + } + + cite { + text-transform: none; + font-style: normal; + } + } +} + +.wp-block-pullquote cite { + color: inherit; +} diff --git a/gb-src/packages/block-library/src/pullquote/theme.scss b/gb-src/packages/block-library/src/pullquote/theme.scss new file mode 100644 index 0000000000000..46bb986758629 --- /dev/null +++ b/gb-src/packages/block-library/src/pullquote/theme.scss @@ -0,0 +1,15 @@ +.wp-block-pullquote { + border-top: 4px solid $dark-gray-500; + border-bottom: 4px solid $dark-gray-500; + margin-bottom: $default-block-margin; + color: $dark-gray-600; + + cite, + footer, + &__citation { + color: $dark-gray-600; + text-transform: uppercase; + font-size: $default-font-size; + font-style: normal; + } +} diff --git a/gb-src/packages/block-library/src/quote/block.json b/gb-src/packages/block-library/src/quote/block.json new file mode 100644 index 0000000000000..05b07ad5bb2d1 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/block.json @@ -0,0 +1,22 @@ +{ + "name": "core/quote", + "category": "common", + "attributes": { + "value": { + "type": "string", + "source": "html", + "selector": "blockquote", + "multiline": "p", + "default": "" + }, + "citation": { + "type": "string", + "source": "html", + "selector": "cite", + "default": "" + }, + "align": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/quote/deprecated.js b/gb-src/packages/block-library/src/quote/deprecated.js new file mode 100644 index 0000000000000..5b8637138a2ae --- /dev/null +++ b/gb-src/packages/block-library/src/quote/deprecated.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +const blockAttributes = { + value: { + type: 'string', + source: 'html', + selector: 'blockquote', + multiline: 'p', + default: '', + }, + citation: { + type: 'string', + source: 'html', + selector: 'cite', + default: '', + }, + align: { + type: 'string', + }, +}; + +const deprecated = [ + { + attributes: blockAttributes, + save( { attributes } ) { + const { align, value, citation } = attributes; + + return ( + <blockquote style={ { textAlign: align ? align : null } }> + <RichText.Content multiline value={ value } /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + ); + }, + }, + { + attributes: { + ...blockAttributes, + style: { + type: 'number', + default: 1, + }, + }, + + migrate( attributes ) { + if ( attributes.style === 2 ) { + return { + ...omit( attributes, [ 'style' ] ), + className: attributes.className ? attributes.className + ' is-style-large' : 'is-style-large', + }; + } + + return attributes; + }, + + save( { attributes } ) { + const { align, value, citation, style } = attributes; + + return ( + <blockquote + className={ style === 2 ? 'is-large' : '' } + style={ { textAlign: align ? align : null } } + > + <RichText.Content multiline value={ value } /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + ); + }, + }, + { + attributes: { + ...blockAttributes, + citation: { + type: 'string', + source: 'html', + selector: 'footer', + default: '', + }, + style: { + type: 'number', + default: 1, + }, + }, + + save( { attributes } ) { + const { align, value, citation, style } = attributes; + + return ( + <blockquote + className={ `blocks-quote-style-${ style }` } + style={ { textAlign: align ? align : null } } + > + <RichText.Content multiline value={ value } /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="footer" value={ citation } /> } + </blockquote> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/quote/edit.js b/gb-src/packages/block-library/src/quote/edit.js new file mode 100644 index 0000000000000..32793f60692a6 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/edit.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { AlignmentToolbar, BlockControls, RichText } from '@wordpress/block-editor'; +import { BlockQuotation } from '@wordpress/components'; +import { createBlock } from '@wordpress/blocks'; + +export default function QuoteEdit( { attributes, setAttributes, isSelected, mergeBlocks, onReplace, className } ) { + const { align, value, citation } = attributes; + + return ( + <> + <BlockControls> + <AlignmentToolbar + value={ align } + onChange={ ( nextAlign ) => { + setAttributes( { align: nextAlign } ); + } } + /> + </BlockControls> + <BlockQuotation + className={ classnames( className, { + [ `has-text-align-${ align }` ]: align, + } ) } + > + <RichText + identifier="value" + multiline + value={ value } + onChange={ + ( nextValue ) => setAttributes( { + value: nextValue, + } ) + } + onMerge={ mergeBlocks } + onRemove={ ( forward ) => { + const hasEmptyCitation = ! citation || citation.length === 0; + if ( ! forward && hasEmptyCitation ) { + onReplace( [] ); + } + } } + placeholder={ + // translators: placeholder text used for the quote + __( 'Write quote…' ) + } + onReplace={ onReplace } + onSplit={ ( piece ) => + createBlock( 'core/quote', { + ...attributes, + value: piece, + } ) + } + __unstableOnSplitMiddle={ () => + createBlock( 'core/paragraph' ) + } + /> + { ( ! RichText.isEmpty( citation ) || isSelected ) && ( + <RichText + identifier="citation" + value={ citation } + onChange={ + ( nextCitation ) => setAttributes( { + citation: nextCitation, + } ) + } + __unstableMobileNoFocusOnMount + placeholder={ + // translators: placeholder text used for the citation + __( 'Write citation…' ) + } + className="wp-block-quote__citation" + /> + ) } + </BlockQuotation> + </> + ); +} diff --git a/gb-src/packages/block-library/src/quote/editor.scss b/gb-src/packages/block-library/src/quote/editor.scss new file mode 100644 index 0000000000000..12455da81199b --- /dev/null +++ b/gb-src/packages/block-library/src/quote/editor.scss @@ -0,0 +1,3 @@ +.wp-block-quote__citation { + font-size: $default-font-size; +} diff --git a/gb-src/packages/block-library/src/quote/icon.js b/gb-src/packages/block-library/src/quote/icon.js new file mode 100644 index 0000000000000..22de4ef2da181 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M18.62 18h-5.24l2-4H13V6h8v7.24L18.62 18zm-2-2h.76L19 12.76V8h-4v4h3.62l-2 4zm-8 2H3.38l2-4H3V6h8v7.24L8.62 18zm-2-2h.76L9 12.76V8H5v4h3.62l-2 4z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/quote/index.js b/gb-src/packages/block-library/src/quote/index.js new file mode 100644 index 0000000000000..e7d733d41e3d1 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/index.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Quote' ), + description: __( 'Give quoted text visual emphasis. "In quoting others, we cite ourselves." — Julio Cortázar' ), + icon, + keywords: [ __( 'blockquote' ), __( 'cite' ) ], + example: { + attributes: { + value: '<p>' + __( 'In quoting others, we cite ourselves.' ) + '</p>', + citation: 'Julio Cortázar', + className: 'is-style-large', + }, + }, + styles: [ + { name: 'default', label: _x( 'Default', 'block style' ), isDefault: true }, + { name: 'large', label: _x( 'Large', 'block style' ) }, + ], + transforms, + edit, + save, + merge( attributes, { value, citation } ) { + // Quote citations cannot be merged. Pick the second one unless it's + // empty. + if ( ! citation ) { + citation = attributes.citation; + } + + if ( ! value || value === '<p></p>' ) { + return { + ...attributes, + citation, + }; + } + + return { + ...attributes, + value: attributes.value + value, + citation, + }; + }, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/quote/save.js b/gb-src/packages/block-library/src/quote/save.js new file mode 100644 index 0000000000000..15d8445d7a740 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/save.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { align, value, citation } = attributes; + + const className = classnames( { + [ `has-text-align-${ align }` ]: align, + } ); + + return ( + <blockquote className={ className }> + <RichText.Content multiline value={ value } /> + { ! RichText.isEmpty( citation ) && <RichText.Content tagName="cite" value={ citation } /> } + </blockquote> + ); +} diff --git a/gb-src/packages/block-library/src/quote/style.scss b/gb-src/packages/block-library/src/quote/style.scss new file mode 100644 index 0000000000000..c1206d68958f7 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/style.scss @@ -0,0 +1,19 @@ +.wp-block-quote { + &.is-style-large, + &.is-large { + margin: 0 0 16px; + padding: 0 1em; + + p { + font-size: 24px; + font-style: italic; + line-height: 1.6; + } + + cite, + footer { + font-size: 18px; + text-align: right; + } + } +} diff --git a/gb-src/packages/block-library/src/quote/theme.scss b/gb-src/packages/block-library/src/quote/theme.scss new file mode 100644 index 0000000000000..fe3d90cd6a256 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/theme.scss @@ -0,0 +1,34 @@ +.wp-block-quote { + border-left: 4px solid $black; + margin: 0 0 $default-block-margin 0; + padding-left: 1em; + + cite, + footer, + &__citation { + color: $dark-gray-300; + font-size: $default-font-size; + margin-top: 1em; + position: relative; + font-style: normal; + } + + &.has-text-align-right, + &.has-text-align-right { + border-left: none; + border-right: 4px solid $black; + padding-left: 0; + padding-right: 1em; + } + + &.has-text-align-center, + &.has-text-align-center { + border: none; + padding-left: 0; + } + + &.is-style-large, + &.is-large { + border: none; + } +} diff --git a/gb-src/packages/block-library/src/quote/transforms.js b/gb-src/packages/block-library/src/quote/transforms.js new file mode 100644 index 0000000000000..c05c8b4b50a79 --- /dev/null +++ b/gb-src/packages/block-library/src/quote/transforms.js @@ -0,0 +1,175 @@ +/** + * WordPress dependencies + */ +import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; +import { create, join, split, toHTMLString } from '@wordpress/rich-text'; + +const transforms = { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ 'core/paragraph' ], + transform: ( attributes ) => { + return createBlock( 'core/quote', { + value: toHTMLString( { + value: join( attributes.map( ( { content } ) => + create( { html: content } ) + ), '\u2028' ), + multilineTag: 'p', + } ), + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/heading' ], + transform: ( { content } ) => { + return createBlock( 'core/quote', { + value: `<p>${ content }</p>`, + } ); + }, + }, + { + type: 'block', + blocks: [ 'core/pullquote' ], + transform: ( { value, citation } ) => createBlock( 'core/quote', { + value, + citation, + } ), + }, + { + type: 'prefix', + prefix: '>', + transform: ( content ) => { + return createBlock( 'core/quote', { + value: `<p>${ content }</p>`, + } ); + }, + }, + { + type: 'raw', + isMatch: ( node ) => { + const isParagraphOrSingleCite = ( () => { + let hasCitation = false; + return ( child ) => { + // Child is a paragraph. + if ( child.nodeName === 'P' ) { + return true; + } + // Child is a cite and no other cite child exists before it. + if ( + ! hasCitation && + child.nodeName === 'CITE' + ) { + hasCitation = true; + return true; + } + }; + } )(); + return node.nodeName === 'BLOCKQUOTE' && + // The quote block can only handle multiline paragraph + // content with an optional cite child. + Array.from( node.childNodes ).every( + isParagraphOrSingleCite + ); + }, + schema: { + blockquote: { + children: { + p: { + children: getPhrasingContentSchema(), + }, + cite: { + children: getPhrasingContentSchema(), + }, + }, + }, + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { value, citation } ) => { + const paragraphs = []; + if ( value && value !== '<p></p>' ) { + paragraphs.push( + ...split( create( { html: value, multilineTag: 'p' } ), '\u2028' ) + .map( ( piece ) => + createBlock( 'core/paragraph', { + content: toHTMLString( { value: piece } ), + } ) + ) + ); + } + if ( citation && citation !== '<p></p>' ) { + paragraphs.push( + createBlock( 'core/paragraph', { + content: citation, + } ) + ); + } + + if ( paragraphs.length === 0 ) { + return createBlock( 'core/paragraph', { + content: '', + } ); + } + return paragraphs; + }, + }, + + { + type: 'block', + blocks: [ 'core/heading' ], + transform: ( { value, citation, ...attrs } ) => { + // If there is no quote content, use the citation as the + // content of the resulting heading. A nonexistent citation + // will result in an empty heading. + if ( value === '<p></p>' ) { + return createBlock( 'core/heading', { + content: citation, + } ); + } + + const pieces = split( create( { html: value, multilineTag: 'p' } ), '\u2028' ); + + const headingBlock = createBlock( 'core/heading', { + content: toHTMLString( { value: pieces[ 0 ] } ), + } ); + + if ( ! citation && pieces.length === 1 ) { + return headingBlock; + } + + const quotePieces = pieces.slice( 1 ); + + const quoteBlock = createBlock( 'core/quote', { + ...attrs, + citation, + value: toHTMLString( { + value: quotePieces.length ? join( pieces.slice( 1 ), '\u2028' ) : create(), + multilineTag: 'p', + } ), + } ); + + return [ headingBlock, quoteBlock ]; + }, + }, + + { + type: 'block', + blocks: [ 'core/pullquote' ], + transform: ( { value, citation } ) => { + return createBlock( 'core/pullquote', { + value, + citation, + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/rss/edit.js b/gb-src/packages/block-library/src/rss/edit.js new file mode 100644 index 0000000000000..81c6497f35252 --- /dev/null +++ b/gb-src/packages/block-library/src/rss/edit.js @@ -0,0 +1,172 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + Button, + Disabled, + PanelBody, + Placeholder, + RangeControl, + TextControl, + ToggleControl, + Toolbar, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + BlockControls, + InspectorControls, +} from '@wordpress/block-editor'; +import ServerSideRender from '@wordpress/server-side-render'; + +const DEFAULT_MIN_ITEMS = 1; +const DEFAULT_MAX_ITEMS = 10; + +class RSSEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + editing: ! this.props.attributes.feedURL, + }; + + this.toggleAttribute = this.toggleAttribute.bind( this ); + this.onSubmitURL = this.onSubmitURL.bind( this ); + } + + toggleAttribute( propName ) { + return () => { + const value = this.props.attributes[ propName ]; + const { setAttributes } = this.props; + + setAttributes( { [ propName ]: ! value } ); + }; + } + + onSubmitURL( event ) { + event.preventDefault(); + + const { feedURL } = this.props.attributes; + if ( feedURL ) { + this.setState( { editing: false } ); + } + } + + render() { + const { + blockLayout, + columns, + displayAuthor, + displayExcerpt, + displayDate, + excerptLength, + feedURL, + itemsToShow, + } = this.props.attributes; + const { setAttributes } = this.props; + + if ( this.state.editing ) { + return ( + <Placeholder + icon="rss" + label="RSS" + > + <form onSubmit={ this.onSubmitURL }> + <TextControl + placeholder={ __( 'Enter URL here…' ) } + value={ feedURL } + onChange={ ( value ) => setAttributes( { feedURL: value } ) } + className={ 'components-placeholder__input' } + /> + <Button isLarge type="submit"> + { __( 'Use URL' ) } + </Button> + </form> + </Placeholder> + ); + } + + const toolbarControls = [ + { + icon: 'edit', + title: __( 'Edit RSS URL' ), + onClick: () => this.setState( { editing: true } ), + }, + { + icon: 'list-view', + title: __( 'List view' ), + onClick: () => setAttributes( { blockLayout: 'list' } ), + isActive: blockLayout === 'list', + }, + { + icon: 'grid-view', + title: __( 'Grid view' ), + onClick: () => setAttributes( { blockLayout: 'grid' } ), + isActive: blockLayout === 'grid', + }, + ]; + + return ( + <> + <BlockControls> + <Toolbar controls={ toolbarControls } /> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'RSS Settings' ) }> + <RangeControl + label={ __( 'Number of items' ) } + value={ itemsToShow } + onChange={ ( value ) => setAttributes( { itemsToShow: value } ) } + min={ DEFAULT_MIN_ITEMS } + max={ DEFAULT_MAX_ITEMS } + required + /> + <ToggleControl + label={ __( 'Display author' ) } + checked={ displayAuthor } + onChange={ this.toggleAttribute( 'displayAuthor' ) } + /> + <ToggleControl + label={ __( 'Display date' ) } + checked={ displayDate } + onChange={ this.toggleAttribute( 'displayDate' ) } + /> + <ToggleControl + label={ __( 'Display excerpt' ) } + checked={ displayExcerpt } + onChange={ this.toggleAttribute( 'displayExcerpt' ) } + /> + { displayExcerpt && + <RangeControl + label={ __( 'Max number of words in excerpt' ) } + value={ excerptLength } + onChange={ ( value ) => setAttributes( { excerptLength: value } ) } + min={ 10 } + max={ 100 } + required + /> + } + { blockLayout === 'grid' && + <RangeControl + label={ __( 'Columns' ) } + value={ columns } + onChange={ ( value ) => setAttributes( { columns: value } ) } + min={ 2 } + max={ 6 } + required + /> + } + </PanelBody> + </InspectorControls> + <Disabled> + <ServerSideRender + block="core/rss" + attributes={ this.props.attributes } + /> + </Disabled> + </> + ); + } +} + +export default RSSEdit; diff --git a/gb-src/packages/block-library/src/rss/editor.scss b/gb-src/packages/block-library/src/rss/editor.scss new file mode 100644 index 0000000000000..4cc6b44e5b469 --- /dev/null +++ b/gb-src/packages/block-library/src/rss/editor.scss @@ -0,0 +1,9 @@ +.block-editor .wp-block-rss { + padding-left: 2.5em; + &.is-grid { + padding-left: 0; + } +} +.wp-block-rss li a > div { + display: inline; +} diff --git a/gb-src/packages/block-library/src/rss/index.js b/gb-src/packages/block-library/src/rss/index.js new file mode 100644 index 0000000000000..3ae196e75aea8 --- /dev/null +++ b/gb-src/packages/block-library/src/rss/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; + +export const name = 'core/rss'; + +export const settings = { + title: __( 'RSS' ), + description: __( 'Display entries from any RSS or Atom feed.' ), + icon: 'rss', + category: 'widgets', + keywords: [ __( 'atom' ), __( 'feed' ) ], + supports: { + align: true, + html: false, + }, + example: { + attributes: { + feedURL: 'https://wordpress.org', + }, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/rss/index.php b/gb-src/packages/block-library/src/rss/index.php new file mode 100644 index 0000000000000..44a465ea615ac --- /dev/null +++ b/gb-src/packages/block-library/src/rss/index.php @@ -0,0 +1,160 @@ +<?php +/** + * Server-side rendering of the `core/rss` block. + * + * @package WordPress + */ + +/** + * Renders the `core/rss` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the block content with received rss items. + */ +function render_block_core_rss( $attributes ) { + $rss = fetch_feed( $attributes['feedURL'] ); + + if ( is_wp_error( $rss ) ) { + return '<div class="components-placeholder"><div class="notice notice-error"><strong>' . __( 'RSS Error:' ) . '</strong> ' . esc_html( $rss->get_error_message() ) . '</div></div>'; + } + + if ( ! $rss->get_item_quantity() ) { + // PHP 5.2 compatibility. See: http://simplepie.org/wiki/faq/i_m_getting_memory_leaks. + $rss->__destruct(); + unset( $rss ); + + return '<div class="components-placeholder"><div class="notice notice-error">' . __( 'An error has occurred, which probably means the feed is down. Try again later.' ) . '</div></div>'; + } + + $rss_items = $rss->get_items( 0, $attributes['itemsToShow'] ); + $list_items = ''; + foreach ( $rss_items as $item ) { + $title = esc_html( trim( strip_tags( $item->get_title() ) ) ); + if ( empty( $title ) ) { + $title = __( '(no title)' ); + } + $link = $item->get_link(); + $link = esc_url( $link ); + if ( $link ) { + $title = "<a href='{$link}'>{$title}</a>"; + } + $title = "<div class='wp-block-rss__item-title'>{$title}</div>"; + + $date = ''; + if ( $attributes['displayDate'] ) { + $date = $item->get_date( 'U' ); + + if ( $date ) { + $date = sprintf( + '<time datetime="%1$s" class="wp-block-rss__item-publish-date">%2$s</time> ', + esc_attr( date_i18n( get_option( 'c' ), $date ) ), + esc_attr( date_i18n( get_option( 'date_format' ), $date ) ) + ); + } + } + + $author = ''; + if ( $attributes['displayAuthor'] ) { + $author = $item->get_author(); + if ( is_object( $author ) ) { + $author = $author->get_name(); + $author = '<span class="wp-block-rss__item-author">' . __( 'by' ) . ' ' . esc_html( strip_tags( $author ) ) . '</span>'; + } + } + + $excerpt = ''; + if ( $attributes['displayExcerpt'] ) { + $excerpt = html_entity_decode( $item->get_description(), ENT_QUOTES, get_option( 'blog_charset' ) ); + $excerpt = esc_attr( wp_trim_words( $excerpt, $attributes['excerptLength'], ' […]' ) ); + + // Change existing [...] to […]. + if ( '[...]' === substr( $excerpt, -5 ) ) { + $excerpt = substr( $excerpt, 0, -5 ) . '[…]'; + } + + $excerpt = '<div class="wp-block-rss__item-excerpt">' . esc_html( $excerpt ) . '</div>'; + } + + $list_items .= "<li class='wp-block-rss__item'>{$title}{$date}{$author}{$excerpt}</li>"; + } + + $class = 'wp-block-rss'; + if ( isset( $attributes['align'] ) ) { + $class .= ' align' . $attributes['align']; + } + + if ( isset( $attributes['blockLayout'] ) && 'grid' === $attributes['blockLayout'] ) { + $class .= ' is-grid'; + } + + if ( isset( $attributes['columns'] ) && 'grid' === $attributes['blockLayout'] ) { + $class .= ' columns-' . $attributes['columns']; + } + + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + $list_items_markup = sprintf( "<ul class='%s'>%s</ul>", esc_attr( $class ), $list_items ); + + // PHP 5.2 compatibility. See: http://simplepie.org/wiki/faq/i_m_getting_memory_leaks. + $rss->__destruct(); + unset( $rss ); + + return $list_items_markup; +} + +/** + * Registers the `core/rss` block on server. + */ +function register_block_core_rss() { + register_block_type( + 'core/rss', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'columns' => array( + 'type' => 'number', + 'default' => 2, + ), + 'blockLayout' => array( + 'type' => 'string', + 'default' => 'list', + ), + 'feedURL' => array( + 'type' => 'string', + 'default' => '', + ), + 'itemsToShow' => array( + 'type' => 'number', + 'default' => 5, + ), + 'displayExcerpt' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'displayAuthor' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'displayDate' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'excerptLength' => array( + 'type' => 'number', + 'default' => 55, + ), + ), + 'render_callback' => 'render_block_core_rss', + ) + ); +} +add_action( 'init', 'register_block_core_rss' ); diff --git a/gb-src/packages/block-library/src/rss/style.scss b/gb-src/packages/block-library/src/rss/style.scss new file mode 100644 index 0000000000000..14bae6e85bde3 --- /dev/null +++ b/gb-src/packages/block-library/src/rss/style.scss @@ -0,0 +1,36 @@ +.wp-block-rss { + &.alignleft { + /*rtl:ignore*/ + margin-right: 2em; + } + &.alignright { + /*rtl:ignore*/ + margin-left: 2em; + } + &.is-grid { + display: flex; + flex-wrap: wrap; + padding: 0; + list-style: none; + + li { + margin: 0 16px 16px 0; + width: 100%; + } + } + + @include break-small { + @for $i from 2 through 6 { + &.columns-#{ $i } li { + width: calc(( 100% / #{ $i } ) - 16px); + } + } + } +} + +.wp-block-rss__item-publish-date, +.wp-block-rss__item-author { + display: block; + color: $dark-gray-300; + font-size: $default-font-size; +} diff --git a/gb-src/packages/block-library/src/search/edit.js b/gb-src/packages/block-library/src/search/edit.js new file mode 100644 index 0000000000000..81c2ec63d8acb --- /dev/null +++ b/gb-src/packages/block-library/src/search/edit.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +export default function SearchEdit( { className, attributes, setAttributes } ) { + const { label, placeholder, buttonText } = attributes; + + return ( + <div className={ className }> + <RichText + wrapperClassName="wp-block-search__label" + aria-label={ __( 'Label text' ) } + placeholder={ __( 'Add label…' ) } + withoutInteractiveFormatting + value={ label } + onChange={ ( html ) => setAttributes( { label: html } ) } + /> + <input + className="wp-block-search__input" + aria-label={ __( 'Optional placeholder text' ) } + // We hide the placeholder field's placeholder when there is a value. This + // stops screen readers from reading the placeholder field's placeholder + // which is confusing. + placeholder={ placeholder ? undefined : __( 'Optional placeholder…' ) } + value={ placeholder } + onChange={ ( event ) => setAttributes( { placeholder: event.target.value } ) } + /> + <RichText + wrapperClassName="wp-block-search__button" + className="wp-block-search__button-rich-text" + aria-label={ __( 'Button text' ) } + placeholder={ __( 'Add button text…' ) } + withoutInteractiveFormatting + value={ buttonText } + onChange={ ( html ) => setAttributes( { buttonText: html } ) } + /> + </div> + ); +} diff --git a/gb-src/packages/block-library/src/search/editor.scss b/gb-src/packages/block-library/src/search/editor.scss new file mode 100644 index 0000000000000..55ed303443062 --- /dev/null +++ b/gb-src/packages/block-library/src/search/editor.scss @@ -0,0 +1,26 @@ +.wp-block-search { + .wp-block-search__input { + border-radius: $radius-round-rectangle; + border: 1px solid $dark-gray-200; + color: $dark-opacity-300; + font-family: $default-font; + font-size: $default-font-size; + + &:focus { + outline: none; + } + } + + .wp-block-search__button { + background: #f7f7f7; + border-radius: $radius-round-rectangle; + border: 1px solid #ccc; + box-shadow: inset 0 -1px 0 #ccc; + font-family: $default-font; + font-size: $default-font-size; + + .wp-block-search__button-rich-text { + padding: 6px 10px; + } + } +} diff --git a/gb-src/packages/block-library/src/search/index.js b/gb-src/packages/block-library/src/search/index.js new file mode 100644 index 0000000000000..eb9ab982e831f --- /dev/null +++ b/gb-src/packages/block-library/src/search/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; + +export const name = 'core/search'; + +export const settings = { + title: __( 'Search' ), + description: __( 'Help visitors find your content.' ), + icon: 'search', + category: 'widgets', + keywords: [ __( 'find' ) ], + supports: { + align: true, + }, + example: {}, + edit, +}; diff --git a/gb-src/packages/block-library/src/search/index.php b/gb-src/packages/block-library/src/search/index.php new file mode 100644 index 0000000000000..c50b69a4411ad --- /dev/null +++ b/gb-src/packages/block-library/src/search/index.php @@ -0,0 +1,93 @@ +<?php +/** + * Server-side rendering of the `core/search` block. + * + * @package WordPress + */ + +/** + * Dynamically renders the `core/search` block. + * + * @param array $attributes The block attributes. + * + * @return string The search block markup. + */ +function render_block_core_search( $attributes ) { + static $instance_id = 0; + + $input_id = 'wp-block-search__input-' . ++$instance_id; + $label_markup = ''; + $button_markup = ''; + + if ( ! empty( $attributes['label'] ) ) { + $label_markup = sprintf( + '<label for="%s" class="wp-block-search__label">%s</label>', + $input_id, + $attributes['label'] + ); + } + + $input_markup = sprintf( + '<input type="search" id="%s" class="wp-block-search__input" name="s" value="%s" placeholder="%s" />', + $input_id, + esc_attr( get_search_query() ), + esc_attr( $attributes['placeholder'] ) + ); + + if ( ! empty( $attributes['buttonText'] ) ) { + $button_markup = sprintf( + '<button type="submit" class="wp-block-search__button">%s</button>', + $attributes['buttonText'] + ); + } + + $class = 'wp-block-search'; + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + if ( isset( $attributes['align'] ) ) { + $class .= ' align' . $attributes['align']; + } + + return sprintf( + '<form class="%s" role="search" method="get" action="%s">%s</form>', + esc_attr( $class ), + esc_url( home_url( '/' ) ), + $label_markup . $input_markup . $button_markup + ); +} + +/** + * Registers the `core/search` block on the server. + */ +function register_block_core_search() { + register_block_type( + 'core/search', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'label' => array( + 'type' => 'string', + 'default' => __( 'Search' ), + ), + 'placeholder' => array( + 'type' => 'string', + 'default' => '', + ), + 'buttonText' => array( + 'type' => 'string', + 'default' => __( 'Search' ), + ), + ), + 'render_callback' => 'render_block_core_search', + ) + ); +} +add_action( 'init', 'register_block_core_search' ); diff --git a/gb-src/packages/block-library/src/search/style.scss b/gb-src/packages/block-library/src/search/style.scss new file mode 100644 index 0000000000000..80a8a20b5fd37 --- /dev/null +++ b/gb-src/packages/block-library/src/search/style.scss @@ -0,0 +1,16 @@ +.wp-block-search { + display: flex; + flex-wrap: wrap; + + .wp-block-search__label { + width: 100%; + } + + .wp-block-search__input { + flex-grow: 1; + } + + .wp-block-search__button { + margin-left: 10px; + } +} diff --git a/gb-src/packages/block-library/src/search/theme.scss b/gb-src/packages/block-library/src/search/theme.scss new file mode 100644 index 0000000000000..f95849183b5f6 --- /dev/null +++ b/gb-src/packages/block-library/src/search/theme.scss @@ -0,0 +1,5 @@ +.wp-block-search { + .wp-block-search__label { + font-weight: bold; + } +} diff --git a/gb-src/packages/block-library/src/separator/block.json b/gb-src/packages/block-library/src/separator/block.json new file mode 100644 index 0000000000000..98bcbb9150609 --- /dev/null +++ b/gb-src/packages/block-library/src/separator/block.json @@ -0,0 +1,12 @@ +{ + "name": "core/separator", + "category": "layout", + "attributes": { + "color": { + "type": "string" + }, + "customColor": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/separator/edit.js b/gb-src/packages/block-library/src/separator/edit.js new file mode 100644 index 0000000000000..050bd7423fb66 --- /dev/null +++ b/gb-src/packages/block-library/src/separator/edit.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { HorizontalRule } from '@wordpress/components'; +import { + InspectorControls, + withColors, + PanelColorSettings, +} from '@wordpress/block-editor'; + +function SeparatorEdit( { color, setColor, className } ) { + return ( + <> + <HorizontalRule + className={ classnames( + className, { + 'has-background': color.color, + [ color.class ]: color.class, + } + ) } + style={ { + backgroundColor: color.color, + color: color.color, + } } + /> + <InspectorControls> + <PanelColorSettings + title={ __( 'Color Settings' ) } + colorSettings={ [ + { + value: color.color, + onChange: setColor, + label: __( 'Color' ), + }, + ] } + > + </PanelColorSettings> + </InspectorControls> + </> + ); +} + +export default withColors( 'color', { textColor: 'color' } )( SeparatorEdit ); diff --git a/gb-src/packages/block-library/src/separator/icon.js b/gb-src/packages/block-library/src/separator/icon.js new file mode 100644 index 0000000000000..3fcea1436545b --- /dev/null +++ b/gb-src/packages/block-library/src/separator/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M19 13H5v-2h14v2z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/separator/index.js b/gb-src/packages/block-library/src/separator/index.js new file mode 100644 index 0000000000000..37948d1d727ae --- /dev/null +++ b/gb-src/packages/block-library/src/separator/index.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Separator' ), + description: __( 'Create a break between ideas or sections with a horizontal separator.' ), + icon, + keywords: [ __( 'horizontal-line' ), 'hr', __( 'divider' ) ], + example: { + attributes: { + customColor: '#065174', + className: 'is-style-wide', + + }, + }, + styles: [ + { name: 'default', label: __( 'Default' ), isDefault: true }, + { name: 'wide', label: __( 'Wide Line' ) }, + { name: 'dots', label: __( 'Dots' ) }, + ], + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/separator/save.js b/gb-src/packages/block-library/src/separator/save.js new file mode 100644 index 0000000000000..d9b899486233c --- /dev/null +++ b/gb-src/packages/block-library/src/separator/save.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + getColorClassName, +} from '@wordpress/block-editor'; + +export default function separatorSave( { attributes } ) { + const { + color, + customColor, + } = attributes; + + // the hr support changing color using border-color, since border-color + // is not yet supported in the color palette, we use background-color + const backgroundClass = getColorClassName( 'background-color', color ); + // the dots styles uses text for the dots, to change those dots color is + // using color, not backgroundColor + const colorClass = getColorClassName( 'color', color ); + + const separatorClasses = classnames( { + 'has-text-color has-background': color || customColor, + [ backgroundClass ]: backgroundClass, + [ colorClass ]: colorClass, + } ); + + const separatorStyle = { + backgroundColor: backgroundClass ? undefined : customColor, + color: colorClass ? undefined : customColor, + }; + + return ( <hr + className={ separatorClasses } + style={ separatorStyle } + /> ); +} diff --git a/gb-src/packages/block-library/src/separator/style.scss b/gb-src/packages/block-library/src/separator/style.scss new file mode 100644 index 0000000000000..a4cf1f2fdb6d2 --- /dev/null +++ b/gb-src/packages/block-library/src/separator/style.scss @@ -0,0 +1,29 @@ +.wp-block-separator { + // Default, thin style, is stored in theme.scss so it can be opted out of + + // Wide style + &.is-style-wide { + border-bottom-width: 1px; + } + + // Dots style + &.is-style-dots { + // Override any background themes often set on the hr tag for this style. + // also override the color set in the editor since it's intented for normal HR + background: none !important; + border: none; + text-align: center; + max-width: none; + line-height: 1; + height: auto; + + &::before { + content: "\00b7 \00b7 \00b7"; + color: currentColor; + font-size: 20px; + letter-spacing: 2em; + padding-left: 2em; + font-family: serif; + } + } +} diff --git a/gb-src/packages/block-library/src/separator/theme.scss b/gb-src/packages/block-library/src/separator/theme.scss new file mode 100644 index 0000000000000..f0673d7bb6f5d --- /dev/null +++ b/gb-src/packages/block-library/src/separator/theme.scss @@ -0,0 +1,20 @@ +.wp-block-separator { + border: none; + border-bottom: 2px solid $dark-gray-100; + margin-left: auto; + margin-right: auto; + + // Default, thin style + &:not(.is-style-wide):not(.is-style-dots) { + max-width: 100px; + } + + &.has-background:not(.is-style-dots) { + border-bottom: none; + height: 1px; + } + + &.has-background:not(.is-style-wide):not(.is-style-dots) { + height: 2px; + } +} diff --git a/gb-src/packages/block-library/src/separator/transforms.js b/gb-src/packages/block-library/src/separator/transforms.js new file mode 100644 index 0000000000000..06d9d667caf63 --- /dev/null +++ b/gb-src/packages/block-library/src/separator/transforms.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'enter', + regExp: /^-{3,}$/, + transform: () => createBlock( 'core/separator' ), + }, + { + type: 'raw', + selector: 'hr', + schema: { + hr: {}, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/shortcode/edit.js b/gb-src/packages/block-library/src/shortcode/edit.js new file mode 100644 index 0000000000000..5a9dbf7f5c795 --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/edit.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dashicon } from '@wordpress/components'; +import { PlainText } from '@wordpress/block-editor'; +import { withInstanceId } from '@wordpress/compose'; + +const ShortcodeEdit = ( { attributes, setAttributes, instanceId } ) => { + const inputId = `blocks-shortcode-input-${ instanceId }`; + + return ( + <div className="wp-block-shortcode components-placeholder"> + <label htmlFor={ inputId } className="components-placeholder__label"> + <Dashicon icon="shortcode" /> + { __( 'Shortcode' ) } + </label> + <PlainText + className="input-control" + id={ inputId } + value={ attributes.text } + placeholder={ __( 'Write shortcode here…' ) } + onChange={ ( text ) => setAttributes( { text } ) } + /> + </div> + ); +}; + +export default withInstanceId( ShortcodeEdit ); diff --git a/gb-src/packages/block-library/src/shortcode/editor.scss b/gb-src/packages/block-library/src/shortcode/editor.scss new file mode 100644 index 0000000000000..3ad5cf8940024 --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/editor.scss @@ -0,0 +1,26 @@ +.wp-block-shortcode { + display: flex; + flex-direction: column; + padding: $block-padding; + background-color: $dark-opacity-light-200; + font-size: $default-font-size; + font-family: $default-font; + margin-bottom: $default-block-margin; + + label { + display: flex; + align-items: center; + white-space: nowrap; + font-weight: 600; + flex-shrink: 0; + } + + .block-editor-plain-text { + width: 80%; + max-height: 250px; + } + + .dashicon { + margin-right: $grid-size; + } +} diff --git a/gb-src/packages/block-library/src/shortcode/icon.js b/gb-src/packages/block-library/src/shortcode/icon.js new file mode 100644 index 0000000000000..b60538f397629 --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M8.5,21.4l1.9,0.5l5.2-19.3l-1.9-0.5L8.5,21.4z M3,19h4v-2H5V7h2V5H3V19z M17,5v2h2v10h-2v2h4V5H17z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/shortcode/index.js b/gb-src/packages/block-library/src/shortcode/index.js new file mode 100644 index 0000000000000..d1ccb00e17d9b --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import save from './save'; +import transforms from './transforms'; + +export const name = 'core/shortcode'; + +export const settings = { + title: __( 'Shortcode' ), + description: __( 'Insert additional custom elements with a WordPress shortcode.' ), + icon, + category: 'widgets', + transforms, + supports: { + customClassName: false, + className: false, + html: false, + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/shortcode/index.php b/gb-src/packages/block-library/src/shortcode/index.php new file mode 100644 index 0000000000000..79df091f8a3d3 --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/index.php @@ -0,0 +1,37 @@ +<?php +/** + * Server-side rendering of the `core/shortcode` block. + * + * @package WordPress + */ + +/** + * Performs wpautop() on the shortcode block content. + * + * @param array $attributes The block attributes. + * @param string $content The block content. + * + * @return string Returns the block content. + */ +function render_block_core_shortcode( $attributes, $content ) { + return wpautop( $content ); +} + +/** + * Registers the `core/shortcode` block on server. + */ +function register_block_core_shortcode() { + register_block_type( + 'core/shortcode', + array( + 'attributes' => array( + 'text' => array( + 'type' => 'string', + 'source' => 'html', + ), + ), + 'render_callback' => 'render_block_core_shortcode', + ) + ); +} +add_action( 'init', 'register_block_core_shortcode' ); diff --git a/gb-src/packages/block-library/src/shortcode/save.js b/gb-src/packages/block-library/src/shortcode/save.js new file mode 100644 index 0000000000000..a745788b1e6c0 --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function save( { attributes } ) { + return <RawHTML>{ attributes.text }</RawHTML>; +} diff --git a/gb-src/packages/block-library/src/shortcode/transforms.js b/gb-src/packages/block-library/src/shortcode/transforms.js new file mode 100644 index 0000000000000..eb0f7a0538dfe --- /dev/null +++ b/gb-src/packages/block-library/src/shortcode/transforms.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { removep, autop } from '@wordpress/autop'; + +const transforms = { + from: [ + { + type: 'shortcode', + // Per "Shortcode names should be all lowercase and use all + // letters, but numbers and underscores should work fine too. + // Be wary of using hyphens (dashes), you'll be better off not + // using them." in https://codex.wordpress.org/Shortcode_API + // Require that the first character be a letter. This notably + // prevents footnote markings ([1]) from being caught as + // shortcodes. + tag: '[a-z][a-z0-9_-]*', + attributes: { + text: { + type: 'string', + shortcode: ( attrs, { content } ) => { + return removep( autop( content ) ); + }, + }, + }, + priority: 20, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/social-link/edit.js b/gb-src/packages/block-library/src/social-link/edit.js new file mode 100644 index 0000000000000..986de58e60b92 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/edit.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * WordPress dependencies + */ +import { URLPopover } from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { + Button, + IconButton, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getIconBySite } from './social-list'; + +const SocialLinkEdit = ( { attributes, setAttributes, isSelected } ) => { + const { url, site } = attributes; + const [ showURLPopover, setPopover ] = useState( false ); + const classes = classNames( + 'wp-social-link', + 'wp-social-link-' + site, + { 'wp-social-link__is-incomplete': ! url }, + ); + + // Import icon. + const IconComponent = getIconBySite( site ); + + return ( + <Button + className={ classes } + onClick={ () => setPopover( true ) } + > + <IconComponent /> + { isSelected && showURLPopover && ( + <URLPopover + onClose={ () => setPopover( false ) } + > + <form + className="block-editor-url-popover__link-editor" + onSubmit={ ( event ) => { + event.preventDefault(); + setPopover( false ); + } } > + <div className="editor-url-input block-editor-url-input"> + <input type="text" + value={ url } + onChange={ ( event ) => setAttributes( { url: event.target.value } ) } + placeholder={ __( 'Enter Address' ) } + /> + </div> + <IconButton icon="editor-break" label={ __( 'Apply' ) } type="submit" /> + </form> + </URLPopover> + ) } + </Button> + ); +}; + +export default SocialLinkEdit; diff --git a/gb-src/packages/block-library/src/social-link/editor.scss b/gb-src/packages/block-library/src/social-link/editor.scss new file mode 100644 index 0000000000000..092507468286f --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/editor.scss @@ -0,0 +1,10 @@ +.wp-social-link { + // Frontend outputs a link, backend a button. Both should have the same padding. + // Therefore, the following exactly matches the padding on the frontend link. + padding: 6px; +} + +.wp-block-social-links.is-style-pill-shape .wp-social-link { + padding-left: 16px; + padding-right: 16px; +} diff --git a/gb-src/packages/block-library/src/social-link/icons/amazon.js b/gb-src/packages/block-library/src/social-link/icons/amazon.js new file mode 100644 index 0000000000000..cf906e4671e03 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/amazon.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const AmazonIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M13.582,8.182C11.934,8.367,9.78,8.49,8.238,9.166c-1.781,0.769-3.03,2.337-3.03,4.644 c0,2.953,1.86,4.429,4.253,4.429c2.02,0,3.125-0.477,4.685-2.065c0.516,0.747,0.685,1.109,1.629,1.894 c0.212,0.114,0.483,0.103,0.672-0.066l0.006,0.006c0.567-0.505,1.599-1.401,2.18-1.888c0.231-0.188,0.19-0.496,0.009-0.754 c-0.52-0.718-1.072-1.303-1.072-2.634V8.305c0-1.876,0.133-3.599-1.249-4.891C15.23,2.369,13.422,2,12.04,2 C9.336,2,6.318,3.01,5.686,6.351C5.618,6.706,5.877,6.893,6.109,6.945l2.754,0.298C9.121,7.23,9.308,6.977,9.357,6.72 c0.236-1.151,1.2-1.706,2.284-1.706c0.584,0,1.249,0.215,1.595,0.738c0.398,0.584,0.346,1.384,0.346,2.061V8.182z M13.049,14.088 c-0.451,0.8-1.169,1.291-1.967,1.291c-1.09,0-1.728-0.83-1.728-2.061c0-2.42,2.171-2.86,4.227-2.86v0.615 C13.582,12.181,13.608,13.104,13.049,14.088z M20.683,19.339C18.329,21.076,14.917,22,11.979,22c-4.118,0-7.826-1.522-10.632-4.057 c-0.22-0.199-0.024-0.471,0.241-0.317c3.027,1.762,6.771,2.823,10.639,2.823c2.608,0,5.476-0.541,8.115-1.66 C20.739,18.62,21.072,19.051,20.683,19.339z M21.336,21.043c-0.194,0.163-0.379,0.076-0.293-0.139 c0.284-0.71,0.92-2.298,0.619-2.684c-0.301-0.386-1.99-0.183-2.749-0.092c-0.23,0.027-0.266-0.173-0.059-0.319 c1.348-0.946,3.555-0.673,3.811-0.356C22.925,17.773,22.599,19.986,21.336,21.043z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/bandcamp.js b/gb-src/packages/block-library/src/social-link/icons/bandcamp.js new file mode 100644 index 0000000000000..4033d7406b760 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/bandcamp.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const BandcampIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M15.27 17.289 3 17.289 8.73 6.711 21 6.711 15.27 17.289" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/behance.js b/gb-src/packages/block-library/src/social-link/icons/behance.js new file mode 100644 index 0000000000000..2dc0cc5cd81e1 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/behance.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const BehanceIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M7.799,5.698c0.589,0,1.12,0.051,1.606,0.156c0.482,0.102,0.894,0.273,1.241,0.507c0.344,0.235,0.612,0.546,0.804,0.938 c0.188,0.387,0.281,0.871,0.281,1.443c0,0.619-0.141,1.137-0.421,1.551c-0.284,0.413-0.7,0.751-1.255,1.014 c0.756,0.218,1.317,0.601,1.689,1.146c0.374,0.549,0.557,1.205,0.557,1.975c0,0.623-0.12,1.161-0.359,1.612 c-0.241,0.457-0.569,0.828-0.973,1.114c-0.408,0.288-0.876,0.5-1.399,0.637C9.052,17.931,8.514,18,7.963,18H2V5.698H7.799 M7.449,10.668c0.481,0,0.878-0.114,1.192-0.345c0.311-0.228,0.463-0.603,0.463-1.119c0-0.286-0.051-0.523-0.152-0.707 C8.848,8.315,8.711,8.171,8.536,8.07C8.362,7.966,8.166,7.894,7.94,7.854c-0.224-0.044-0.457-0.06-0.697-0.06H4.709v2.874H7.449z M7.6,15.905c0.267,0,0.521-0.024,0.759-0.077c0.243-0.053,0.457-0.137,0.637-0.261c0.182-0.12,0.332-0.283,0.441-0.491 C9.547,14.87,9.6,14.602,9.6,14.278c0-0.633-0.18-1.084-0.533-1.357c-0.356-0.27-0.83-0.404-1.413-0.404H4.709v3.388L7.6,15.905z M16.162,15.864c0.367,0.358,0.897,0.538,1.583,0.538c0.493,0,0.92-0.125,1.277-0.374c0.354-0.248,0.571-0.514,0.654-0.79h2.155 c-0.347,1.072-0.872,1.838-1.589,2.299C19.534,18,18.67,18.23,17.662,18.23c-0.701,0-1.332-0.113-1.899-0.337 c-0.567-0.227-1.041-0.544-1.439-0.958c-0.389-0.415-0.689-0.907-0.904-1.484c-0.213-0.574-0.32-1.21-0.32-1.899 c0-0.666,0.11-1.288,0.329-1.863c0.222-0.577,0.529-1.075,0.933-1.492c0.406-0.42,0.885-0.751,1.444-0.994 c0.558-0.241,1.175-0.363,1.857-0.363c0.754,0,1.414,0.145,1.98,0.44c0.563,0.291,1.026,0.686,1.389,1.181 c0.363,0.493,0.622,1.057,0.783,1.69c0.16,0.632,0.217,1.292,0.171,1.983h-6.428C15.557,14.84,15.795,15.506,16.162,15.864 M18.973,11.184c-0.291-0.321-0.783-0.496-1.384-0.496c-0.39,0-0.714,0.066-0.973,0.2c-0.254,0.132-0.461,0.297-0.621,0.491 c-0.157,0.197-0.265,0.405-0.328,0.628c-0.063,0.217-0.101,0.413-0.111,0.587h3.98C19.478,11.969,19.265,11.509,18.973,11.184z M15.057,7.738h4.985V6.524h-4.985L15.057,7.738z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/chain.js b/gb-src/packages/block-library/src/social-link/icons/chain.js new file mode 100644 index 0000000000000..5347ff89f3e16 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/chain.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const ChainIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M19.647,16.706a1.134,1.134,0,0,0-.343-.833l-2.549-2.549a1.134,1.134,0,0,0-.833-.343,1.168,1.168,0,0,0-.883.392l.233.226q.2.189.264.264a2.922,2.922,0,0,1,.184.233.986.986,0,0,1,.159.312,1.242,1.242,0,0,1,.043.337,1.172,1.172,0,0,1-1.176,1.176,1.237,1.237,0,0,1-.337-.043,1,1,0,0,1-.312-.159,2.76,2.76,0,0,1-.233-.184q-.073-.068-.264-.264l-.226-.233a1.19,1.19,0,0,0-.4.895,1.134,1.134,0,0,0,.343.833L15.837,19.3a1.13,1.13,0,0,0,.833.331,1.18,1.18,0,0,0,.833-.318l1.8-1.789a1.12,1.12,0,0,0,.343-.821Zm-8.615-8.64a1.134,1.134,0,0,0-.343-.833L8.163,4.7a1.134,1.134,0,0,0-.833-.343,1.184,1.184,0,0,0-.833.331L4.7,6.473a1.12,1.12,0,0,0-.343.821,1.134,1.134,0,0,0,.343.833l2.549,2.549a1.13,1.13,0,0,0,.833.331,1.184,1.184,0,0,0,.883-.38L8.728,10.4q-.2-.189-.264-.264A2.922,2.922,0,0,1,8.28,9.9a.986.986,0,0,1-.159-.312,1.242,1.242,0,0,1-.043-.337A1.172,1.172,0,0,1,9.254,8.079a1.237,1.237,0,0,1,.337.043,1,1,0,0,1,.312.159,2.761,2.761,0,0,1,.233.184q.073.068.264.264l.226.233a1.19,1.19,0,0,0,.4-.895ZM22,16.706a3.343,3.343,0,0,1-1.042,2.488l-1.8,1.789a3.536,3.536,0,0,1-4.988-.025l-2.525-2.537a3.384,3.384,0,0,1-1.017-2.488,3.448,3.448,0,0,1,1.078-2.561l-1.078-1.078a3.434,3.434,0,0,1-2.549,1.078,3.4,3.4,0,0,1-2.5-1.029L3.029,9.794A3.4,3.4,0,0,1,2,7.294,3.343,3.343,0,0,1,3.042,4.806l1.8-1.789A3.384,3.384,0,0,1,7.331,2a3.357,3.357,0,0,1,2.5,1.042l2.525,2.537a3.384,3.384,0,0,1,1.017,2.488,3.448,3.448,0,0,1-1.078,2.561l1.078,1.078a3.551,3.551,0,0,1,5.049-.049l2.549,2.549A3.4,3.4,0,0,1,22,16.706Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/codepen.js b/gb-src/packages/block-library/src/social-link/icons/codepen.js new file mode 100644 index 0000000000000..0528466f4eb53 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/codepen.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const CodepenIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M22.016,8.84c-0.002-0.013-0.005-0.025-0.007-0.037c-0.005-0.025-0.008-0.048-0.015-0.072 c-0.003-0.015-0.01-0.028-0.013-0.042c-0.008-0.02-0.015-0.04-0.023-0.062c-0.007-0.015-0.013-0.028-0.02-0.042 c-0.008-0.02-0.018-0.037-0.03-0.057c-0.007-0.013-0.017-0.027-0.025-0.038c-0.012-0.018-0.023-0.035-0.035-0.052 c-0.01-0.013-0.02-0.025-0.03-0.037c-0.015-0.017-0.028-0.032-0.043-0.045c-0.01-0.012-0.022-0.023-0.035-0.035 c-0.015-0.015-0.032-0.028-0.048-0.04c-0.012-0.01-0.025-0.02-0.037-0.03c-0.005-0.003-0.01-0.008-0.015-0.012l-9.161-6.096 c-0.289-0.192-0.666-0.192-0.955,0L2.359,8.237C2.354,8.24,2.349,8.245,2.344,8.249L2.306,8.277 c-0.017,0.013-0.033,0.027-0.048,0.04C2.246,8.331,2.234,8.342,2.222,8.352c-0.015,0.015-0.028,0.03-0.042,0.047 c-0.012,0.013-0.022,0.023-0.03,0.037C2.139,8.453,2.125,8.471,2.115,8.488C2.107,8.501,2.099,8.514,2.09,8.526 C2.079,8.548,2.069,8.565,2.06,8.585C2.054,8.6,2.047,8.613,2.04,8.626C2.032,8.648,2.025,8.67,2.019,8.69 c-0.005,0.013-0.01,0.027-0.013,0.042C1.999,8.755,1.995,8.778,1.99,8.803C1.989,8.817,1.985,8.828,1.984,8.84 C1.978,8.879,1.975,8.915,1.975,8.954v6.093c0,0.037,0.003,0.075,0.008,0.112c0.002,0.012,0.005,0.025,0.007,0.038 c0.005,0.023,0.008,0.047,0.015,0.072c0.003,0.015,0.008,0.028,0.013,0.04c0.007,0.022,0.013,0.042,0.022,0.063 c0.007,0.015,0.013,0.028,0.02,0.04c0.008,0.02,0.018,0.038,0.03,0.058c0.007,0.013,0.015,0.027,0.025,0.038 c0.012,0.018,0.023,0.035,0.035,0.052c0.01,0.013,0.02,0.025,0.03,0.037c0.013,0.015,0.028,0.032,0.042,0.045 c0.012,0.012,0.023,0.023,0.035,0.035c0.015,0.013,0.032,0.028,0.048,0.04l0.038,0.03c0.005,0.003,0.01,0.007,0.013,0.01 l9.163,6.095C11.668,21.953,11.833,22,12,22c0.167,0,0.332-0.047,0.478-0.144l9.163-6.095l0.015-0.01 c0.013-0.01,0.027-0.02,0.037-0.03c0.018-0.013,0.035-0.028,0.048-0.04c0.013-0.012,0.025-0.023,0.035-0.035 c0.017-0.015,0.03-0.032,0.043-0.045c0.01-0.013,0.02-0.025,0.03-0.037c0.013-0.018,0.025-0.035,0.035-0.052 c0.008-0.013,0.018-0.027,0.025-0.038c0.012-0.02,0.022-0.038,0.03-0.058c0.007-0.013,0.013-0.027,0.02-0.04 c0.008-0.022,0.015-0.042,0.023-0.063c0.003-0.013,0.01-0.027,0.013-0.04c0.007-0.025,0.01-0.048,0.015-0.072 c0.002-0.013,0.005-0.027,0.007-0.037c0.003-0.042,0.007-0.079,0.007-0.117V8.954C22.025,8.915,22.022,8.879,22.016,8.84z M12.862,4.464l6.751,4.49l-3.016,2.013l-3.735-2.492V4.464z M11.138,4.464v4.009l-3.735,2.494L4.389,8.954L11.138,4.464z M3.699,10.562L5.853,12l-2.155,1.438V10.562z M11.138,19.536l-6.749-4.491l3.015-2.011l3.735,2.492V19.536z M12,14.035L8.953,12 L12,9.966L15.047,12L12,14.035z M12.862,19.536v-4.009l3.735-2.492l3.016,2.011L12.862,19.536z M20.303,13.438L18.147,12 l2.156-1.438L20.303,13.438z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/deviantart.js b/gb-src/packages/block-library/src/social-link/icons/deviantart.js new file mode 100644 index 0000000000000..17b2c25f832fb --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/deviantart.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const DeviantartIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M 18.19 5.636 18.19 2 18.188 2 14.553 2 14.19 2.366 12.474 5.636 11.935 6 5.81 6 5.81 10.994 9.177 10.994 9.477 11.357 5.81 18.363 5.81 22 5.811 22 9.447 22 9.81 21.634 11.526 18.364 12.065 18 18.19 18 18.19 13.006 14.823 13.006 14.523 12.641 18.19 5.636z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/dribbble.js b/gb-src/packages/block-library/src/social-link/icons/dribbble.js new file mode 100644 index 0000000000000..e1d20d706a600 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/dribbble.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const DribbbleIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12,22C6.486,22,2,17.514,2,12S6.486,2,12,2c5.514,0,10,4.486,10,10S17.514,22,12,22z M20.434,13.369 c-0.292-0.092-2.644-0.794-5.32-0.365c1.117,3.07,1.572,5.57,1.659,6.09C18.689,17.798,20.053,15.745,20.434,13.369z M15.336,19.876c-0.127-0.749-0.623-3.361-1.822-6.477c-0.019,0.006-0.038,0.013-0.056,0.019c-4.818,1.679-6.547,5.02-6.701,5.334 c1.448,1.129,3.268,1.803,5.243,1.803C13.183,20.555,14.311,20.313,15.336,19.876z M5.654,17.724 c0.193-0.331,2.538-4.213,6.943-5.637c0.111-0.036,0.224-0.07,0.337-0.102c-0.214-0.485-0.448-0.971-0.692-1.45 c-4.266,1.277-8.405,1.223-8.778,1.216c-0.003,0.087-0.004,0.174-0.004,0.261C3.458,14.207,4.29,16.21,5.654,17.724z M3.639,10.264 c0.382,0.005,3.901,0.02,7.897-1.041c-1.415-2.516-2.942-4.631-3.167-4.94C5.979,5.41,4.193,7.613,3.639,10.264z M9.998,3.709 c0.236,0.316,1.787,2.429,3.187,5c3.037-1.138,4.323-2.867,4.477-3.085C16.154,4.286,14.17,3.471,12,3.471 C11.311,3.471,10.641,3.554,9.998,3.709z M18.612,6.612C18.432,6.855,17,8.69,13.842,9.979c0.199,0.407,0.389,0.821,0.567,1.237 c0.063,0.148,0.124,0.295,0.184,0.441c2.842-0.357,5.666,0.215,5.948,0.275C20.522,9.916,19.801,8.065,18.612,6.612z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/dropbox.js b/gb-src/packages/block-library/src/social-link/icons/dropbox.js new file mode 100644 index 0000000000000..15eb1224623ed --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/dropbox.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const DropboxIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12,6.134L6.069,9.797L2,6.54l5.883-3.843L12,6.134z M2,13.054l5.883,3.843L12,13.459L6.069,9.797L2,13.054z M12,13.459 l4.116,3.439L22,13.054l-4.069-3.257L12,13.459z M22,6.54l-5.884-3.843L12,6.134l5.931,3.663L22,6.54z M12.011,14.2l-4.129,3.426 l-1.767-1.153v1.291l5.896,3.539l5.897-3.539v-1.291l-1.769,1.153L12.011,14.2z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/etsy.js b/gb-src/packages/block-library/src/social-link/icons/etsy.js new file mode 100644 index 0000000000000..73f3cd95ccc1c --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/etsy.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const EtsyIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M9.16033,4.038c0-.27174.02717-.43478.48913-.43478h6.22283c1.087,0,1.68478.92391,2.11957,2.663l.35326,1.38587h1.05978C19.59511,3.712,19.75815,2,19.75815,2s-2.663.29891-4.23913.29891h-7.962L3.29076,2.163v1.1413L4.731,3.57609c1.00543.19022,1.25.40761,1.33152,1.33152,0,0,.08152,2.71739.08152,7.20109s-.08152,7.17391-.08152,7.17391c0,.81522-.32609,1.11413-1.33152,1.30435l-1.44022.27174V22l4.2663-.13587h7.11957c1.60326,0,5.32609.13587,5.32609.13587.08152-.97826.625-5.40761.70652-5.89674H19.7038L18.644,18.52174c-.84239,1.90217-2.06522,2.038-3.42391,2.038H11.1712c-1.3587,0-2.01087-.54348-2.01087-1.712V12.65217s3.0163,0,3.99457.08152c.76087.05435,1.22283.27174,1.46739,1.33152l.32609,1.413h1.16848l-.08152-3.55978.163-3.587H15.02989l-.38043,1.57609c-.24457,1.03261-.40761,1.22283-1.46739,1.33152-1.38587.13587-4.02174.1087-4.02174.1087Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/facebook.js b/gb-src/packages/block-library/src/social-link/icons/facebook.js new file mode 100644 index 0000000000000..13b9111625bbc --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/facebook.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const FacebookIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12 2C6.5 2 2 6.5 2 12c0 5 3.7 9.1 8.4 9.9v-7H7.9V12h2.5V9.8c0-2.5 1.5-3.9 3.8-3.9 1.1 0 2.2.2 2.2.2v2.5h-1.3c-1.2 0-1.6.8-1.6 1.6V12h2.8l-.4 2.9h-2.3v7C18.3 21.1 22 17 22 12c0-5.5-4.5-10-10-10z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/feed.js b/gb-src/packages/block-library/src/social-link/icons/feed.js new file mode 100644 index 0000000000000..6386cef02bb2f --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/feed.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const FeedIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M2,8.667V12c5.515,0,10,4.485,10,10h3.333C15.333,14.637,9.363,8.667,2,8.667z M2,2v3.333 c9.19,0,16.667,7.477,16.667,16.667H22C22,10.955,13.045,2,2,2z M4.5,17C3.118,17,2,18.12,2,19.5S3.118,22,4.5,22S7,20.88,7,19.5 S5.882,17,4.5,17z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/fivehundredpx.js b/gb-src/packages/block-library/src/social-link/icons/fivehundredpx.js new file mode 100644 index 0000000000000..6961e2b7e5cc2 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/fivehundredpx.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const FivehundredpxIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M6.94026,15.1412c.00437.01213.108.29862.168.44064a6.55008,6.55008,0,1,0,6.03191-9.09557,6.68654,6.68654,0,0,0-2.58357.51467A8.53914,8.53914,0,0,0,8.21268,8.61344L8.209,8.61725V3.22948l9.0504-.00008c.32934-.0036.32934-.46353.32934-.61466s0-.61091-.33035-.61467L7.47248,2a.43.43,0,0,0-.43131.42692v7.58355c0,.24466.30476.42131.58793.4819.553.11812.68074-.05864.81617-.2457l.018-.02481A10.52673,10.52673,0,0,1,9.32258,9.258a5.35268,5.35268,0,1,1,7.58985,7.54976,5.417,5.417,0,0,1-3.80867,1.56365,5.17483,5.17483,0,0,1-2.69822-.74478l.00342-4.61111a2.79372,2.79372,0,0,1,.71372-1.78792,2.61611,2.61611,0,0,1,1.98282-.89477,2.75683,2.75683,0,0,1,1.95525.79477,2.66867,2.66867,0,0,1,.79656,1.909,2.724,2.724,0,0,1-2.75849,2.748,4.94651,4.94651,0,0,1-.86254-.13719c-.31234-.093-.44519.34058-.48892.48349-.16811.54966.08453.65862.13687.67489a3.75751,3.75751,0,0,0,1.25234.18375,3.94634,3.94634,0,1,0-2.82444-6.742,3.67478,3.67478,0,0,0-1.13028,2.584l-.00041.02323c-.0035.11667-.00579,2.881-.00644,3.78811l-.00407-.00451a6.18521,6.18521,0,0,1-1.0851-1.86092c-.10544-.27856-.34358-.22925-.66857-.12917-.14192.04372-.57386.17677-.47833.489Zm4.65165-1.08338a.51346.51346,0,0,0,.19513.31818l.02276.022a.52945.52945,0,0,0,.3517.18416.24242.24242,0,0,0,.16577-.0611c.05473-.05082.67382-.67812.73287-.738l.69041.68819a.28978.28978,0,0,0,.21437.11032.53239.53239,0,0,0,.35708-.19486c.29792-.30419.14885-.46821.07676-.54751l-.69954-.69975.72952-.73469c.16-.17311.01874-.35708-.12218-.498-.20461-.20461-.402-.25742-.52855-.14083l-.7254.72665-.73354-.73375a.20128.20128,0,0,0-.14179-.05695.54135.54135,0,0,0-.34379.19648c-.22561.22555-.274.38149-.15656.5059l.73374.7315-.72942.73072A.26589.26589,0,0,0,11.59191,14.05782Zm1.59866-9.915A8.86081,8.86081,0,0,0,9.854,4.776a.26169.26169,0,0,0-.16938.22759.92978.92978,0,0,0,.08619.42094c.05682.14524.20779.531.50006.41955a8.40969,8.40969,0,0,1,2.91968-.55484,7.87875,7.87875,0,0,1,3.086.62286,8.61817,8.61817,0,0,1,2.30562,1.49315.2781.2781,0,0,0,.18318.07586c.15529,0,.30425-.15253.43167-.29551.21268-.23861.35873-.4369.1492-.63538a8.50425,8.50425,0,0,0-2.62312-1.694A9.0177,9.0177,0,0,0,13.19058,4.14283ZM19.50945,18.6236h0a.93171.93171,0,0,0-.36642-.25406.26589.26589,0,0,0-.27613.06613l-.06943.06929A7.90606,7.90606,0,0,1,7.60639,18.505a7.57284,7.57284,0,0,1-1.696-2.51537,8.58715,8.58715,0,0,1-.5147-1.77754l-.00871-.04864c-.04939-.25873-.28755-.27684-.62981-.22448-.14234.02178-.5755.088-.53426.39969l.001.00712a9.08807,9.08807,0,0,0,15.406,4.99094c.00193-.00192.04753-.04718.0725-.07436C19.79425,19.16234,19.87422,18.98728,19.50945,18.6236Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/flickr.js b/gb-src/packages/block-library/src/social-link/icons/flickr.js new file mode 100644 index 0000000000000..7bafa14fabd28 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/flickr.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const FlickrIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M6.5,7c-2.75,0-5,2.25-5,5s2.25,5,5,5s5-2.25,5-5S9.25,7,6.5,7z M17.5,7c-2.75,0-5,2.25-5,5s2.25,5,5,5s5-2.25,5-5 S20.25,7,17.5,7z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/foursquare.js b/gb-src/packages/block-library/src/social-link/icons/foursquare.js new file mode 100644 index 0000000000000..1b3b49b8c3c78 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/foursquare.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const FoursquareIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M17.573,2c0,0-9.197,0-10.668,0S5,3.107,5,3.805s0,16.948,0,16.948c0,0.785,0.422,1.077,0.66,1.172 c0.238,0.097,0.892,0.177,1.285-0.275c0,0,5.035-5.843,5.122-5.93c0.132-0.132,0.132-0.132,0.262-0.132h3.26 c1.368,0,1.588-0.977,1.732-1.552c0.078-0.318,0.692-3.428,1.225-6.122l0.675-3.368C19.56,2.893,19.14,2,17.573,2z M16.495,7.22 c-0.053,0.252-0.372,0.518-0.665,0.518c-0.293,0-4.157,0-4.157,0c-0.467,0-0.802,0.318-0.802,0.787v0.508 c0,0.467,0.337,0.798,0.805,0.798c0,0,3.197,0,3.528,0s0.655,0.362,0.583,0.715c-0.072,0.353-0.407,2.102-0.448,2.295 c-0.04,0.193-0.262,0.523-0.655,0.523c-0.33,0-2.88,0-2.88,0c-0.523,0-0.683,0.068-1.033,0.503 c-0.35,0.437-3.505,4.223-3.505,4.223c-0.032,0.035-0.063,0.027-0.063-0.015V4.852c0-0.298,0.26-0.648,0.648-0.648 c0,0,8.228,0,8.562,0c0.315,0,0.61,0.297,0.528,0.683L16.495,7.22z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/github.js b/gb-src/packages/block-library/src/social-link/icons/github.js new file mode 100644 index 0000000000000..4dfcd29a971c3 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/github.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const GithubIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12,2C6.477,2,2,6.477,2,12c0,4.419,2.865,8.166,6.839,9.489c0.5,0.09,0.682-0.218,0.682-0.484 c0-0.236-0.009-0.866-0.014-1.699c-2.782,0.602-3.369-1.34-3.369-1.34c-0.455-1.157-1.11-1.465-1.11-1.465 c-0.909-0.62,0.069-0.608,0.069-0.608c1.004,0.071,1.532,1.03,1.532,1.03c0.891,1.529,2.341,1.089,2.91,0.833 c0.091-0.647,0.349-1.086,0.635-1.337c-2.22-0.251-4.555-1.111-4.555-4.943c0-1.091,0.39-1.984,1.03-2.682 C6.546,8.54,6.202,7.524,6.746,6.148c0,0,0.84-0.269,2.75,1.025C10.295,6.95,11.15,6.84,12,6.836 c0.85,0.004,1.705,0.114,2.504,0.336c1.909-1.294,2.748-1.025,2.748-1.025c0.546,1.376,0.202,2.394,0.1,2.646 c0.64,0.699,1.026,1.591,1.026,2.682c0,3.841-2.337,4.687-4.565,4.935c0.359,0.307,0.679,0.917,0.679,1.852 c0,1.335-0.012,2.415-0.012,2.741c0,0.269,0.18,0.579,0.688,0.481C19.138,20.161,22,16.416,22,12C22,6.477,17.523,2,12,2z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/goodreads.js b/gb-src/packages/block-library/src/social-link/icons/goodreads.js new file mode 100644 index 0000000000000..7997519010ebc --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/goodreads.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const GoodreadsIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M17.3,17.5c-0.2,0.8-0.5,1.4-1,1.9c-0.4,0.5-1,0.9-1.7,1.2C13.9,20.9,13.1,21,12,21c-0.6,0-1.3-0.1-1.9-0.2 c-0.6-0.1-1.1-0.4-1.6-0.7c-0.5-0.3-0.9-0.7-1.2-1.2c-0.3-0.5-0.5-1.1-0.5-1.7h1.5c0.1,0.5,0.2,0.9,0.5,1.2 c0.2,0.3,0.5,0.6,0.9,0.8c0.3,0.2,0.7,0.3,1.1,0.4c0.4,0.1,0.8,0.1,1.2,0.1c1.4,0,2.5-0.4,3.1-1.2c0.6-0.8,1-2,1-3.5v-1.7h0 c-0.4,0.8-0.9,1.4-1.6,1.9c-0.7,0.5-1.5,0.7-2.4,0.7c-1,0-1.9-0.2-2.6-0.5C8.7,15,8.1,14.5,7.7,14c-0.5-0.6-0.8-1.3-1-2.1 c-0.2-0.8-0.3-1.6-0.3-2.5c0-0.9,0.1-1.7,0.4-2.5c0.3-0.8,0.6-1.5,1.1-2c0.5-0.6,1.1-1,1.8-1.4C10.3,3.2,11.1,3,12,3 c0.5,0,0.9,0.1,1.3,0.2c0.4,0.1,0.8,0.3,1.1,0.5c0.3,0.2,0.6,0.5,0.9,0.8c0.3,0.3,0.5,0.6,0.6,1h0V3.4h1.5V15 C17.6,15.9,17.5,16.7,17.3,17.5z M13.8,14.1c0.5-0.3,0.9-0.7,1.3-1.1c0.3-0.5,0.6-1,0.8-1.6c0.2-0.6,0.3-1.2,0.3-1.9 c0-0.6-0.1-1.2-0.2-1.9c-0.1-0.6-0.4-1.2-0.7-1.7c-0.3-0.5-0.7-0.9-1.3-1.2c-0.5-0.3-1.1-0.5-1.9-0.5s-1.4,0.2-1.9,0.5 c-0.5,0.3-1,0.7-1.3,1.2C8.5,6.4,8.3,7,8.1,7.6C8,8.2,7.9,8.9,7.9,9.5c0,0.6,0.1,1.3,0.2,1.9C8.3,12,8.6,12.5,8.9,13 c0.3,0.5,0.8,0.8,1.3,1.1c0.5,0.3,1.1,0.4,1.9,0.4C12.7,14.5,13.3,14.4,13.8,14.1z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/google.js b/gb-src/packages/block-library/src/social-link/icons/google.js new file mode 100644 index 0000000000000..fb7813791be9b --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/google.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const GoogleIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12.02,10.18v3.72v0.01h5.51c-0.26,1.57-1.67,4.22-5.5,4.22c-3.31,0-6.01-2.75-6.01-6.12s2.7-6.12,6.01-6.12 c1.87,0,3.13,0.8,3.85,1.48l2.84-2.76C16.99,2.99,14.73,2,12.03,2c-5.52,0-10,4.48-10,10s4.48,10,10,10c5.77,0,9.6-4.06,9.6-9.77 c0-0.83-0.11-1.42-0.25-2.05H12.02z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/index.js b/gb-src/packages/block-library/src/social-link/icons/index.js new file mode 100644 index 0000000000000..dddbfb2db12fc --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/index.js @@ -0,0 +1,40 @@ + +export * from './amazon'; +export * from './bandcamp'; +export * from './behance'; +export * from './chain'; +export * from './codepen'; +export * from './deviantart'; +export * from './dribbble'; +export * from './dropbox'; +export * from './etsy'; +export * from './facebook'; +export * from './feed'; +export * from './fivehundredpx'; +export * from './flickr'; +export * from './foursquare'; +export * from './goodreads'; +export * from './google'; +export * from './github'; +export * from './instagram'; +export * from './lastfm'; +export * from './linkedin'; +export * from './mail'; +export * from './mastodon'; +export * from './meetup'; +export * from './medium'; +export * from './pinterest'; +export * from './pocket'; +export * from './reddit'; +export * from './skype'; +export * from './snapchat'; +export * from './soundcloud'; +export * from './spotify'; +export * from './tumblr'; +export * from './twitch'; +export * from './twitter'; +export * from './vimeo'; +export * from './vk'; +export * from './wordpress'; +export * from './yelp'; +export * from './youtube'; diff --git a/gb-src/packages/block-library/src/social-link/icons/instagram.js b/gb-src/packages/block-library/src/social-link/icons/instagram.js new file mode 100644 index 0000000000000..01d58e95bac49 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/instagram.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const InstagramIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12,4.622c2.403,0,2.688,0.009,3.637,0.052c0.877,0.04,1.354,0.187,1.671,0.31c0.42,0.163,0.72,0.358,1.035,0.673 c0.315,0.315,0.51,0.615,0.673,1.035c0.123,0.317,0.27,0.794,0.31,1.671c0.043,0.949,0.052,1.234,0.052,3.637 s-0.009,2.688-0.052,3.637c-0.04,0.877-0.187,1.354-0.31,1.671c-0.163,0.42-0.358,0.72-0.673,1.035 c-0.315,0.315-0.615,0.51-1.035,0.673c-0.317,0.123-0.794,0.27-1.671,0.31c-0.949,0.043-1.233,0.052-3.637,0.052 s-2.688-0.009-3.637-0.052c-0.877-0.04-1.354-0.187-1.671-0.31c-0.42-0.163-0.72-0.358-1.035-0.673 c-0.315-0.315-0.51-0.615-0.673-1.035c-0.123-0.317-0.27-0.794-0.31-1.671C4.631,14.688,4.622,14.403,4.622,12 s0.009-2.688,0.052-3.637c0.04-0.877,0.187-1.354,0.31-1.671c0.163-0.42,0.358-0.72,0.673-1.035 c0.315-0.315,0.615-0.51,1.035-0.673c0.317-0.123,0.794-0.27,1.671-0.31C9.312,4.631,9.597,4.622,12,4.622 M12,3 C9.556,3,9.249,3.01,8.289,3.054C7.331,3.098,6.677,3.25,6.105,3.472C5.513,3.702,5.011,4.01,4.511,4.511 c-0.5,0.5-0.808,1.002-1.038,1.594C3.25,6.677,3.098,7.331,3.054,8.289C3.01,9.249,3,9.556,3,12c0,2.444,0.01,2.751,0.054,3.711 c0.044,0.958,0.196,1.612,0.418,2.185c0.23,0.592,0.538,1.094,1.038,1.594c0.5,0.5,1.002,0.808,1.594,1.038 c0.572,0.222,1.227,0.375,2.185,0.418C9.249,20.99,9.556,21,12,21s2.751-0.01,3.711-0.054c0.958-0.044,1.612-0.196,2.185-0.418 c0.592-0.23,1.094-0.538,1.594-1.038c0.5-0.5,0.808-1.002,1.038-1.594c0.222-0.572,0.375-1.227,0.418-2.185 C20.99,14.751,21,14.444,21,12s-0.01-2.751-0.054-3.711c-0.044-0.958-0.196-1.612-0.418-2.185c-0.23-0.592-0.538-1.094-1.038-1.594 c-0.5-0.5-1.002-0.808-1.594-1.038c-0.572-0.222-1.227-0.375-2.185-0.418C14.751,3.01,14.444,3,12,3L12,3z M12,7.378 c-2.552,0-4.622,2.069-4.622,4.622S9.448,16.622,12,16.622s4.622-2.069,4.622-4.622S14.552,7.378,12,7.378z M12,15 c-1.657,0-3-1.343-3-3s1.343-3,3-3s3,1.343,3,3S13.657,15,12,15z M16.804,6.116c-0.596,0-1.08,0.484-1.08,1.08 s0.484,1.08,1.08,1.08c0.596,0,1.08-0.484,1.08-1.08S17.401,6.116,16.804,6.116z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/lastfm.js b/gb-src/packages/block-library/src/social-link/icons/lastfm.js new file mode 100644 index 0000000000000..b615e1bf0fddb --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/lastfm.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const LastfmIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M10.5002,0 C4.7006,0 0,4.70109753 0,10.4998496 C0,16.2989526 4.7006,21 10.5002,21 C16.299,21 21,16.2989526 21,10.4998496 C21,4.70109753 16.299,0 10.5002,0 Z M14.69735,14.7204413 C13.3164,14.7151781 12.4346,14.0870017 11.83445,12.6859357 L11.6816001,12.3451305 L10.35405,9.31011397 C9.92709997,8.26875064 8.85260001,7.57120012 7.68010001,7.57120012 C6.06945001,7.57120012 4.75925001,8.88509738 4.75925001,10.5009524 C4.75925001,12.1164565 6.06945001,13.4303036 7.68010001,13.4303036 C8.77200001,13.4303036 9.76514999,12.827541 10.2719501,11.8567047 C10.2893,11.8235214 10.3239,11.8019673 10.36305,11.8038219 C10.4007,11.8053759 10.43535,11.8287847 10.4504,11.8631709 L10.98655,13.1045863 C11.0016,13.1389726 10.9956,13.17782 10.97225,13.2068931 C10.1605001,14.1995341 8.96020001,14.7683115 7.68010001,14.7683115 C5.33305,14.7683115 3.42340001,12.8535563 3.42340001,10.5009524 C3.42340001,8.14679459 5.33300001,6.23203946 7.68010001,6.23203946 C9.45720002,6.23203946 10.8909,7.19074535 11.6138,8.86359341 C11.6205501,8.88018505 12.3412,10.5707777 12.97445,12.0190621 C13.34865,12.8739575 13.64615,13.3959676 14.6288,13.4291508 C15.5663001,13.4612814 16.25375,12.9121534 16.25375,12.1484869 C16.25375,11.4691321 15.8320501,11.3003585 14.8803,10.98216 C13.2365,10.4397989 12.34495,9.88605929 12.34495,8.51817658 C12.34495,7.1809207 13.26665,6.31615054 14.692,6.31615054 C15.62875,6.31615054 16.3155,6.7286858 16.79215,7.5768142 C16.80495,7.60062396 16.8079001,7.62814302 16.8004001,7.65420843 C16.7929,7.68027384 16.7748,7.70212868 16.7507001,7.713808 L15.86145,8.16900031 C15.8178001,8.19200805 15.7643,8.17807308 15.73565,8.13847371 C15.43295,7.71345711 15.0956,7.52513451 14.6423,7.52513451 C14.05125,7.52513451 13.6220001,7.92899802 13.6220001,8.48649708 C13.6220001,9.17382194 14.1529001,9.34144259 15.0339,9.61923972 C15.14915,9.65578139 15.26955,9.69397731 15.39385,9.73432853 C16.7763,10.1865133 17.57675,10.7311301 17.57675,12.1836251 C17.57685,13.629654 16.3389,14.7204413 14.69735,14.7204413 Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/linkedin.js b/gb-src/packages/block-library/src/social-link/icons/linkedin.js new file mode 100644 index 0000000000000..98cec8a81ca0f --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/linkedin.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const LinkedinIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M19.7,3H4.3C3.582,3,3,3.582,3,4.3v15.4C3,20.418,3.582,21,4.3,21h15.4c0.718,0,1.3-0.582,1.3-1.3V4.3 C21,3.582,20.418,3,19.7,3z M8.339,18.338H5.667v-8.59h2.672V18.338z M7.004,8.574c-0.857,0-1.549-0.694-1.549-1.548 c0-0.855,0.691-1.548,1.549-1.548c0.854,0,1.547,0.694,1.547,1.548C8.551,7.881,7.858,8.574,7.004,8.574z M18.339,18.338h-2.669 v-4.177c0-0.996-0.017-2.278-1.387-2.278c-1.389,0-1.601,1.086-1.601,2.206v4.249h-2.667v-8.59h2.559v1.174h0.037 c0.356-0.675,1.227-1.387,2.526-1.387c2.703,0,3.203,1.779,3.203,4.092V18.338z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/mail.js b/gb-src/packages/block-library/src/social-link/icons/mail.js new file mode 100644 index 0000000000000..8e9847cfa370c --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/mail.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const MailIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M20,4H4C2.895,4,2,4.895,2,6v12c0,1.105,0.895,2,2,2h16c1.105,0,2-0.895,2-2V6C22,4.895,21.105,4,20,4z M20,8.236l-8,4.882 L4,8.236V6h16V8.236z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/mastodon.js b/gb-src/packages/block-library/src/social-link/icons/mastodon.js new file mode 100644 index 0000000000000..1c1a196d28e6d --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/mastodon.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const MastodonIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/medium.js b/gb-src/packages/block-library/src/social-link/icons/medium.js new file mode 100644 index 0000000000000..c15c3ca62728f --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/medium.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const MediumIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M20.962,7.257l-5.457,8.867l-3.923-6.375l3.126-5.08c0.112-0.182,0.319-0.286,0.527-0.286c0.05,0,0.1,0.008,0.149,0.02 c0.039,0.01,0.078,0.023,0.114,0.041l5.43,2.715l0.006,0.003c0.004,0.002,0.007,0.006,0.011,0.008 C20.971,7.191,20.98,7.227,20.962,7.257z M9.86,8.592v5.783l5.14,2.57L9.86,8.592z M15.772,17.331l4.231,2.115 C20.554,19.721,21,19.529,21,19.016V8.835L15.772,17.331z M8.968,7.178L3.665,4.527C3.569,4.479,3.478,4.456,3.395,4.456 C3.163,4.456,3,4.636,3,4.938v11.45c0,0.306,0.224,0.669,0.498,0.806l4.671,2.335c0.12,0.06,0.234,0.088,0.337,0.088 c0.29,0,0.494-0.225,0.494-0.602V7.231C9,7.208,8.988,7.188,8.968,7.178z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/meetup.js b/gb-src/packages/block-library/src/social-link/icons/meetup.js new file mode 100644 index 0000000000000..a1f6d86e009a9 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/meetup.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const MeetupIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M19.24775,14.722a3.57032,3.57032,0,0,1-2.94457,3.52073,3.61886,3.61886,0,0,1-.64652.05634c-.07314-.0008-.10187.02846-.12507.09547A2.38881,2.38881,0,0,1,13.49453,20.094a2.33092,2.33092,0,0,1-1.827-.50716.13635.13635,0,0,0-.19878-.00408,3.191,3.191,0,0,1-2.104.60248,3.26309,3.26309,0,0,1-3.00324-2.71993,2.19076,2.19076,0,0,1-.03512-.30865c-.00156-.08579-.03413-.1189-.11608-.13493a2.86421,2.86421,0,0,1-1.23189-.56111,2.945,2.945,0,0,1-1.166-2.05749,2.97484,2.97484,0,0,1,.87524-2.50774.112.112,0,0,0,.02091-.16107,2.7213,2.7213,0,0,1-.36648-1.48A2.81256,2.81256,0,0,1,6.57673,7.58838a.35764.35764,0,0,0,.28869-.22819,4.2208,4.2208,0,0,1,6.02892-1.90111.25161.25161,0,0,0,.22023.0243,3.65608,3.65608,0,0,1,3.76031.90678A3.57244,3.57244,0,0,1,17.95918,8.626a2.97339,2.97339,0,0,1,.01829.57356.10637.10637,0,0,0,.0853.12792,1.97669,1.97669,0,0,1,1.27939,1.33733,2.00266,2.00266,0,0,1-.57112,2.12652c-.05284.05166-.04168.08328-.01173.13489A3.51189,3.51189,0,0,1,19.24775,14.722Zm-6.35959-.27836a1.6984,1.6984,0,0,0,1.14556,1.61113,3.82039,3.82039,0,0,0,1.036.17935,1.46888,1.46888,0,0,0,.73509-.12255.44082.44082,0,0,0,.26057-.44274.45312.45312,0,0,0-.29211-.43375.97191.97191,0,0,0-.20678-.063c-.21326-.03806-.42754-.0701-.63973-.11215a.54787.54787,0,0,1-.50172-.60926,2.75864,2.75864,0,0,1,.1773-.901c.1763-.535.414-1.045.64183-1.55913A12.686,12.686,0,0,0,15.85,10.47863a1.58461,1.58461,0,0,0,.04861-.87208,1.04531,1.04531,0,0,0-.85432-.83981,1.60658,1.60658,0,0,0-1.23654.16594.27593.27593,0,0,1-.36286-.03413c-.085-.0747-.16594-.15379-.24918-.23055a.98682.98682,0,0,0-1.33577-.04933,6.1468,6.1468,0,0,1-.4989.41615.47762.47762,0,0,1-.51535.03566c-.17448-.09307-.35512-.175-.53531-.25665a1.74949,1.74949,0,0,0-.56476-.2016,1.69943,1.69943,0,0,0-1.61654.91787,8.05815,8.05815,0,0,0-.32952.80126c-.45471,1.2557-.82507,2.53825-1.20838,3.81639a1.24151,1.24151,0,0,0,.51532,1.44389,1.42659,1.42659,0,0,0,1.22008.17166,1.09728,1.09728,0,0,0,.66994-.69764c.44145-1.04111.839-2.09989,1.25981-3.14926.11581-.28876.22792-.57874.35078-.86438a.44548.44548,0,0,1,.69189-.19539.50521.50521,0,0,1,.15044.43836,1.75625,1.75625,0,0,1-.14731.50453c-.27379.69219-.55265,1.38236-.82766,2.074a2.0836,2.0836,0,0,0-.14038.42876.50719.50719,0,0,0,.27082.57722.87236.87236,0,0,0,.66145.02739.99137.99137,0,0,0,.53406-.532q.61571-1.20914,1.228-2.42031.28423-.55863.57585-1.1133a.87189.87189,0,0,1,.29055-.35253.34987.34987,0,0,1,.37634-.01265.30291.30291,0,0,1,.12434.31459.56716.56716,0,0,1-.04655.1915c-.05318.12739-.10286.25669-.16183.38156-.34118.71775-.68754,1.43273-1.02568,2.152A2.00213,2.00213,0,0,0,12.88816,14.44366Zm4.78568,5.28972a.88573.88573,0,0,0-1.77139.00465.8857.8857,0,0,0,1.77139-.00465Zm-14.83838-7.296a.84329.84329,0,1,0,.00827-1.68655.8433.8433,0,0,0-.00827,1.68655Zm10.366-9.43673a.83506.83506,0,1,0-.0091,1.67.83505.83505,0,0,0,.0091-1.67Zm6.85014,5.22a.71651.71651,0,0,0-1.433.0093.71656.71656,0,0,0,1.433-.0093ZM5.37528,6.17908A.63823.63823,0,1,0,6.015,5.54483.62292.62292,0,0,0,5.37528,6.17908Zm6.68214,14.80843a.54949.54949,0,1,0-.55052.541A.54556.54556,0,0,0,12.05742,20.98752Zm8.53235-8.49689a.54777.54777,0,0,0-.54027.54023.53327.53327,0,0,0,.532.52293.51548.51548,0,0,0,.53272-.5237A.53187.53187,0,0,0,20.58977,12.49063ZM7.82846,2.4715a.44927.44927,0,1,0,.44484.44766A.43821.43821,0,0,0,7.82846,2.4715Zm13.775,7.60492a.41186.41186,0,0,0-.40065.39623.40178.40178,0,0,0,.40168.40168A.38994.38994,0,0,0,22,10.48172.39946.39946,0,0,0,21.60349,10.07642ZM5.79193,17.96207a.40469.40469,0,0,0-.397-.39646.399.399,0,0,0-.396.405.39234.39234,0,0,0,.39939.389A.39857.39857,0,0,0,5.79193,17.96207Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/pinterest.js b/gb-src/packages/block-library/src/social-link/icons/pinterest.js new file mode 100644 index 0000000000000..d84704a9ba884 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/pinterest.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const PinterestIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12.289,2C6.617,2,3.606,5.648,3.606,9.622c0,1.846,1.025,4.146,2.666,4.878c0.25,0.111,0.381,0.063,0.439-0.169 c0.044-0.175,0.267-1.029,0.365-1.428c0.032-0.128,0.017-0.237-0.091-0.362C6.445,11.911,6.01,10.75,6.01,9.668 c0-2.777,2.194-5.464,5.933-5.464c3.23,0,5.49,2.108,5.49,5.122c0,3.407-1.794,5.768-4.13,5.768c-1.291,0-2.257-1.021-1.948-2.277 c0.372-1.495,1.089-3.112,1.089-4.191c0-0.967-0.542-1.775-1.663-1.775c-1.319,0-2.379,1.309-2.379,3.059 c0,1.115,0.394,1.869,0.394,1.869s-1.302,5.279-1.54,6.261c-0.405,1.666,0.053,4.368,0.094,4.604 c0.021,0.126,0.167,0.169,0.25,0.063c0.129-0.165,1.699-2.419,2.142-4.051c0.158-0.59,0.817-2.995,0.817-2.995 c0.43,0.784,1.681,1.446,3.013,1.446c3.963,0,6.822-3.494,6.822-7.833C20.394,5.112,16.849,2,12.289,2" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/pocket.js b/gb-src/packages/block-library/src/social-link/icons/pocket.js new file mode 100644 index 0000000000000..532138ff04f99 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/pocket.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const PocketIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M21.927,4.194C21.667,3.48,20.982,3,20.222,3h-0.01h-1.721H3.839C3.092,3,2.411,3.47,2.145,4.17 C2.066,4.378,2.026,4.594,2.026,4.814v6.035l0.069,1.2c0.29,2.73,1.707,5.115,3.899,6.778c0.039,0.03,0.079,0.059,0.119,0.089 l0.025,0.018c1.175,0.859,2.491,1.441,3.91,1.727c0.655,0.132,1.325,0.2,1.991,0.2c0.615,0,1.232-0.057,1.839-0.17 c0.073-0.014,0.145-0.028,0.219-0.044c0.02-0.004,0.042-0.012,0.064-0.023c1.359-0.297,2.621-0.864,3.753-1.691l0.025-0.018 c0.04-0.029,0.08-0.058,0.119-0.089c2.192-1.664,3.609-4.049,3.898-6.778l0.069-1.2V4.814C22.026,4.605,22,4.398,21.927,4.194z M17.692,10.481l-4.704,4.512c-0.266,0.254-0.608,0.382-0.949,0.382c-0.342,0-0.684-0.128-0.949-0.382l-4.705-4.512 C5.838,9.957,5.82,9.089,6.344,8.542c0.524-0.547,1.392-0.565,1.939-0.04l3.756,3.601l3.755-3.601 c0.547-0.524,1.415-0.506,1.939,0.04C18.256,9.089,18.238,9.956,17.692,10.481z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/reddit.js b/gb-src/packages/block-library/src/social-link/icons/reddit.js new file mode 100644 index 0000000000000..4a8c7bd14abf7 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/reddit.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const RedditIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M22,11.816c0-1.256-1.021-2.277-2.277-2.277c-0.593,0-1.122,0.24-1.526,0.614c-1.481-0.965-3.455-1.594-5.647-1.69 l1.171-3.702l3.18,0.748c0.008,1.028,0.846,1.862,1.876,1.862c1.035,0,1.877-0.842,1.877-1.878c0-1.035-0.842-1.877-1.877-1.877 c-0.769,0-1.431,0.466-1.72,1.13l-3.508-0.826c-0.203-0.047-0.399,0.067-0.46,0.261l-1.35,4.268 c-2.316,0.038-4.411,0.67-5.97,1.671C5.368,9.765,4.853,9.539,4.277,9.539C3.021,9.539,2,10.56,2,11.816 c0,0.814,0.433,1.523,1.078,1.925c-0.037,0.221-0.061,0.444-0.061,0.672c0,3.292,4.011,5.97,8.941,5.97s8.941-2.678,8.941-5.97 c0-0.214-0.02-0.424-0.053-0.632C21.533,13.39,22,12.661,22,11.816z M18.776,4.394c0.606,0,1.1,0.493,1.1,1.1s-0.493,1.1-1.1,1.1 s-1.1-0.494-1.1-1.1S18.169,4.394,18.776,4.394z M2.777,11.816c0-0.827,0.672-1.5,1.499-1.5c0.313,0,0.598,0.103,0.838,0.269 c-0.851,0.676-1.477,1.479-1.812,2.36C2.983,12.672,2.777,12.27,2.777,11.816z M11.959,19.606c-4.501,0-8.164-2.329-8.164-5.193 S7.457,9.22,11.959,9.22s8.164,2.329,8.164,5.193S16.46,19.606,11.959,19.606z M20.636,13.001c-0.326-0.89-0.948-1.701-1.797-2.384 c0.248-0.186,0.55-0.301,0.883-0.301c0.827,0,1.5,0.673,1.5,1.5C21.223,12.299,20.992,12.727,20.636,13.001z M8.996,14.704 c-0.76,0-1.397-0.616-1.397-1.376c0-0.76,0.637-1.397,1.397-1.397c0.76,0,1.376,0.637,1.376,1.397 C10.372,14.088,9.756,14.704,8.996,14.704z M16.401,13.328c0,0.76-0.616,1.376-1.376,1.376c-0.76,0-1.399-0.616-1.399-1.376 c0-0.76,0.639-1.397,1.399-1.397C15.785,11.931,16.401,12.568,16.401,13.328z M15.229,16.708c0.152,0.152,0.152,0.398,0,0.55 c-0.674,0.674-1.727,1.002-3.219,1.002c-0.004,0-0.007-0.002-0.011-0.002c-0.004,0-0.007,0.002-0.011,0.002 c-1.492,0-2.544-0.328-3.218-1.002c-0.152-0.152-0.152-0.398,0-0.55c0.152-0.152,0.399-0.151,0.55,0 c0.521,0.521,1.394,0.775,2.669,0.775c0.004,0,0.007,0.002,0.011,0.002c0.004,0,0.007-0.002,0.011-0.002 c1.275,0,2.148-0.253,2.669-0.775C14.831,16.556,15.078,16.556,15.229,16.708z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/skype.js b/gb-src/packages/block-library/src/social-link/icons/skype.js new file mode 100644 index 0000000000000..3c34e3f1c5d73 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/skype.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const SkypeIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M10.113,2.699c0.033-0.006,0.067-0.013,0.1-0.02c0.033,0.017,0.066,0.033,0.098,0.051L10.113,2.699z M2.72,10.223 c-0.006,0.034-0.011,0.069-0.017,0.103c0.018,0.032,0.033,0.064,0.051,0.095L2.72,10.223z M21.275,13.771 c0.007-0.035,0.011-0.071,0.018-0.106c-0.018-0.031-0.033-0.064-0.052-0.095L21.275,13.771z M13.563,21.199 c0.032,0.019,0.065,0.035,0.096,0.053c0.036-0.006,0.071-0.011,0.105-0.017L13.563,21.199z M22,16.386 c0,1.494-0.581,2.898-1.637,3.953c-1.056,1.057-2.459,1.637-3.953,1.637c-0.967,0-1.914-0.251-2.75-0.725 c0.036-0.006,0.071-0.011,0.105-0.017l-0.202-0.035c0.032,0.019,0.065,0.035,0.096,0.053c-0.543,0.096-1.099,0.147-1.654,0.147 c-1.275,0-2.512-0.25-3.676-0.743c-1.125-0.474-2.135-1.156-3.002-2.023c-0.867-0.867-1.548-1.877-2.023-3.002 c-0.493-1.164-0.743-2.401-0.743-3.676c0-0.546,0.049-1.093,0.142-1.628c0.018,0.032,0.033,0.064,0.051,0.095L2.72,10.223 c-0.006,0.034-0.011,0.069-0.017,0.103C2.244,9.5,2,8.566,2,7.615c0-1.493,0.582-2.898,1.637-3.953 c1.056-1.056,2.46-1.638,3.953-1.638c0.915,0,1.818,0.228,2.622,0.655c-0.033,0.007-0.067,0.013-0.1,0.02l0.199,0.031 c-0.032-0.018-0.066-0.034-0.098-0.051c0.002,0,0.003-0.001,0.004-0.001c0.586-0.112,1.187-0.169,1.788-0.169 c1.275,0,2.512,0.249,3.676,0.742c1.124,0.476,2.135,1.156,3.002,2.024c0.868,0.867,1.548,1.877,2.024,3.002 c0.493,1.164,0.743,2.401,0.743,3.676c0,0.575-0.054,1.15-0.157,1.712c-0.018-0.031-0.033-0.064-0.052-0.095l0.034,0.201 c0.007-0.035,0.011-0.071,0.018-0.106C21.754,14.494,22,15.432,22,16.386z M16.817,14.138c0-1.331-0.613-2.743-3.033-3.282 l-2.209-0.49c-0.84-0.192-1.807-0.444-1.807-1.237c0-0.794,0.679-1.348,1.903-1.348c2.468,0,2.243,1.696,3.468,1.696 c0.645,0,1.209-0.379,1.209-1.031c0-1.521-2.435-2.663-4.5-2.663c-2.242,0-4.63,0.952-4.63,3.488c0,1.221,0.436,2.521,2.839,3.123 l2.984,0.745c0.903,0.223,1.129,0.731,1.129,1.189c0,0.762-0.758,1.507-2.129,1.507c-2.679,0-2.307-2.062-3.743-2.062 c-0.645,0-1.113,0.444-1.113,1.078c0,1.236,1.501,2.886,4.856,2.886C15.236,17.737,16.817,16.199,16.817,14.138z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/snapchat.js b/gb-src/packages/block-library/src/social-link/icons/snapchat.js new file mode 100644 index 0000000000000..eaddf4d7080e1 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/snapchat.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const SnapchatIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12.065,2a5.526,5.526,0,0,1,3.132.892A5.854,5.854,0,0,1,17.326,5.4a5.821,5.821,0,0,1,.351,2.33q0,.612-.117,2.487a.809.809,0,0,0,.365.091,1.93,1.93,0,0,0,.664-.176,1.93,1.93,0,0,1,.664-.176,1.3,1.3,0,0,1,.729.234.7.7,0,0,1,.351.6.839.839,0,0,1-.41.7,2.732,2.732,0,0,1-.9.41,3.192,3.192,0,0,0-.9.378.728.728,0,0,0-.41.618,1.575,1.575,0,0,0,.156.56,6.9,6.9,0,0,0,1.334,1.953,5.6,5.6,0,0,0,1.881,1.315,5.875,5.875,0,0,0,1.042.3.42.42,0,0,1,.365.456q0,.911-2.852,1.341a1.379,1.379,0,0,0-.143.507,1.8,1.8,0,0,1-.182.605.451.451,0,0,1-.429.241,5.878,5.878,0,0,1-.807-.085,5.917,5.917,0,0,0-.833-.085,4.217,4.217,0,0,0-.807.065,2.42,2.42,0,0,0-.82.293,6.682,6.682,0,0,0-.755.5q-.351.267-.755.527a3.886,3.886,0,0,1-.989.436A4.471,4.471,0,0,1,11.831,22a4.307,4.307,0,0,1-1.256-.176,3.784,3.784,0,0,1-.976-.436q-.4-.26-.749-.527a6.682,6.682,0,0,0-.755-.5,2.422,2.422,0,0,0-.807-.293,4.432,4.432,0,0,0-.82-.065,5.089,5.089,0,0,0-.853.1,5,5,0,0,1-.762.1.474.474,0,0,1-.456-.241,1.819,1.819,0,0,1-.182-.618,1.411,1.411,0,0,0-.143-.521q-2.852-.429-2.852-1.341a.42.42,0,0,1,.365-.456,5.793,5.793,0,0,0,1.042-.3,5.524,5.524,0,0,0,1.881-1.315,6.789,6.789,0,0,0,1.334-1.953A1.575,1.575,0,0,0,6,12.9a.728.728,0,0,0-.41-.618,3.323,3.323,0,0,0-.9-.384,2.912,2.912,0,0,1-.9-.41.814.814,0,0,1-.41-.684.71.71,0,0,1,.338-.593,1.208,1.208,0,0,1,.716-.241,1.976,1.976,0,0,1,.625.169,2.008,2.008,0,0,0,.69.169.919.919,0,0,0,.416-.091q-.117-1.849-.117-2.474A5.861,5.861,0,0,1,6.385,5.4,5.516,5.516,0,0,1,8.625,2.819,7.075,7.075,0,0,1,12.062,2Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/soundcloud.js b/gb-src/packages/block-library/src/social-link/icons/soundcloud.js new file mode 100644 index 0000000000000..9df4e0af59bc4 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/soundcloud.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const SoundcloudIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M8.9,16.1L9,14L8.9,9.5c0-0.1,0-0.1-0.1-0.1c0,0-0.1-0.1-0.1-0.1c-0.1,0-0.1,0-0.1,0.1c0,0-0.1,0.1-0.1,0.1L8.3,14l0.1,2.1 c0,0.1,0,0.1,0.1,0.1c0,0,0.1,0.1,0.1,0.1C8.8,16.3,8.9,16.3,8.9,16.1z M11.4,15.9l0.1-1.8L11.4,9c0-0.1,0-0.2-0.1-0.2 c0,0-0.1,0-0.1,0s-0.1,0-0.1,0c-0.1,0-0.1,0.1-0.1,0.2l0,0.1l-0.1,5c0,0,0,0.7,0.1,2v0c0,0.1,0,0.1,0.1,0.1c0.1,0.1,0.1,0.1,0.2,0.1 c0.1,0,0.1,0,0.2-0.1c0.1,0,0.1-0.1,0.1-0.2L11.4,15.9z M2.4,12.9L2.5,14l-0.2,1.1c0,0.1,0,0.1-0.1,0.1c0,0-0.1,0-0.1-0.1L2.1,14 l0.1-1.1C2.2,12.9,2.3,12.9,2.4,12.9C2.3,12.9,2.4,12.9,2.4,12.9z M3.1,12.2L3.3,14l-0.2,1.8c0,0.1,0,0.1-0.1,0.1 c-0.1,0-0.1,0-0.1-0.1L2.8,14L3,12.2C3,12.2,3,12.2,3.1,12.2C3.1,12.2,3.1,12.2,3.1,12.2z M3.9,11.9L4.1,14l-0.2,2.1 c0,0.1,0,0.1-0.1,0.1c-0.1,0-0.1,0-0.1-0.1L3.5,14l0.2-2.1c0-0.1,0-0.1,0.1-0.1C3.9,11.8,3.9,11.8,3.9,11.9z M4.7,11.9L4.9,14 l-0.2,2.1c0,0.1-0.1,0.1-0.1,0.1c-0.1,0-0.1,0-0.1-0.1L4.3,14l0.2-2.2c0-0.1,0-0.1,0.1-0.1C4.7,11.7,4.7,11.8,4.7,11.9z M5.6,12 l0.2,2l-0.2,2.1c0,0.1-0.1,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0-0.1,0-0.1L5.1,14l0.2-2c0,0,0-0.1,0-0.1s0.1,0,0.1,0 C5.5,11.9,5.5,11.9,5.6,12L5.6,12z M6.4,10.7L6.6,14l-0.2,2.1c0,0,0,0.1,0,0.1c0,0-0.1,0-0.1,0c-0.1,0-0.1-0.1-0.2-0.2L5.9,14 l0.2-3.3c0-0.1,0.1-0.2,0.2-0.2c0,0,0.1,0,0.1,0C6.4,10.7,6.4,10.7,6.4,10.7z M7.2,10l0.2,4.1l-0.2,2.1c0,0,0,0.1,0,0.1 c0,0-0.1,0-0.1,0c-0.1,0-0.2-0.1-0.2-0.2l-0.1-2.1L6.8,10c0-0.1,0.1-0.2,0.2-0.2c0,0,0.1,0,0.1,0S7.2,9.9,7.2,10z M8,9.6L8.2,14 L8,16.1c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L7.5,14l0.1-4.4c0-0.1,0-0.1,0.1-0.1c0,0,0.1-0.1,0.1-0.1c0.1,0,0.1,0,0.1,0.1 C8,9.6,8,9.6,8,9.6z M11.4,16.1L11.4,16.1L11.4,16.1z M9.7,9.6L9.8,14l-0.1,2.1c0,0.1,0,0.1-0.1,0.2s-0.1,0.1-0.2,0.1 c-0.1,0-0.1,0-0.1-0.1s-0.1-0.1-0.1-0.2L9.2,14l0.1-4.4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2,0.1S9.7,9.5,9.7,9.6 L9.7,9.6z M10.6,9.8l0.1,4.3l-0.1,2c0,0.1,0,0.1-0.1,0.2c0,0-0.1,0.1-0.2,0.1c-0.1,0-0.1,0-0.2-0.1c0,0-0.1-0.1-0.1-0.2L10,14 l0.1-4.3c0-0.1,0-0.1,0.1-0.2c0,0,0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2,0.1S10.6,9.7,10.6,9.8z M12.4,14l-0.1,2c0,0.1,0,0.1-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.1,0-0.1,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2l-0.1-1l-0.1-1l0.1-5.5v0c0-0.1,0-0.2,0.1-0.2 c0.1,0,0.1-0.1,0.2-0.1c0,0,0.1,0,0.1,0c0.1,0,0.1,0.1,0.1,0.2L12.4,14z M22.1,13.9c0,0.7-0.2,1.3-0.7,1.7c-0.5,0.5-1.1,0.7-1.7,0.7 h-6.8c-0.1,0-0.1,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2V8.2c0-0.1,0.1-0.2,0.2-0.3c0.5-0.2,1-0.3,1.6-0.3c1.1,0,2.1,0.4,2.9,1.1 c0.8,0.8,1.3,1.7,1.4,2.8c0.3-0.1,0.6-0.2,1-0.2c0.7,0,1.3,0.2,1.7,0.7C21.8,12.6,22.1,13.2,22.1,13.9L22.1,13.9z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/spotify.js b/gb-src/packages/block-library/src/social-link/icons/spotify.js new file mode 100644 index 0000000000000..fe702c4d697d4 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/spotify.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const SpotifyIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12,2C6.477,2,2,6.477,2,12c0,5.523,4.477,10,10,10c5.523,0,10-4.477,10-10C22,6.477,17.523,2,12,2 M16.586,16.424 c-0.18,0.295-0.563,0.387-0.857,0.207c-2.348-1.435-5.304-1.76-8.785-0.964c-0.335,0.077-0.67-0.133-0.746-0.469 c-0.077-0.335,0.132-0.67,0.469-0.746c3.809-0.871,7.077-0.496,9.713,1.115C16.673,15.746,16.766,16.13,16.586,16.424 M17.81,13.7 c-0.226,0.367-0.706,0.482-1.072,0.257c-2.687-1.652-6.785-2.131-9.965-1.166C6.36,12.917,5.925,12.684,5.8,12.273 C5.675,11.86,5.908,11.425,6.32,11.3c3.632-1.102,8.147-0.568,11.234,1.328C17.92,12.854,18.035,13.335,17.81,13.7 M17.915,10.865 c-3.223-1.914-8.54-2.09-11.618-1.156C5.804,9.859,5.281,9.58,5.131,9.086C4.982,8.591,5.26,8.069,5.755,7.919 c3.532-1.072,9.404-0.865,13.115,1.338c0.445,0.264,0.59,0.838,0.327,1.282C18.933,10.983,18.359,11.129,17.915,10.865" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/tumblr.js b/gb-src/packages/block-library/src/social-link/icons/tumblr.js new file mode 100644 index 0000000000000..060a2cf75434e --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/tumblr.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const TumblrIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M16.749,17.396c-0.357,0.17-1.041,0.319-1.551,0.332c-1.539,0.041-1.837-1.081-1.85-1.896V9.847h3.861V6.937h-3.847V2.039 c0,0-2.77,0-2.817,0c-0.046,0-0.127,0.041-0.138,0.144c-0.165,1.499-0.867,4.13-3.783,5.181v2.484h1.945v6.282 c0,2.151,1.587,5.206,5.775,5.135c1.413-0.024,2.982-0.616,3.329-1.126L16.749,17.396z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/twitch.js b/gb-src/packages/block-library/src/social-link/icons/twitch.js new file mode 100644 index 0000000000000..ddce3166b74d2 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/twitch.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const TwitchIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M16.499,8.089h-1.636v4.91h1.636V8.089z M12,8.089h-1.637v4.91H12V8.089z M4.228,3.178L3,6.451v13.092h4.499V22h2.456 l2.454-2.456h3.681L21,14.636V3.178H4.228z M19.364,13.816l-2.864,2.865H12l-2.453,2.453V16.68H5.863V4.814h13.501V13.816z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/twitter.js b/gb-src/packages/block-library/src/social-link/icons/twitter.js new file mode 100644 index 0000000000000..1b6641cadb441 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/twitter.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const TwitterIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M22.23,5.924c-0.736,0.326-1.527,0.547-2.357,0.646c0.847-0.508,1.498-1.312,1.804-2.27 c-0.793,0.47-1.671,0.812-2.606,0.996C18.324,4.498,17.257,4,16.077,4c-2.266,0-4.103,1.837-4.103,4.103 c0,0.322,0.036,0.635,0.106,0.935C8.67,8.867,5.647,7.234,3.623,4.751C3.27,5.357,3.067,6.062,3.067,6.814 c0,1.424,0.724,2.679,1.825,3.415c-0.673-0.021-1.305-0.206-1.859-0.513c0,0.017,0,0.034,0,0.052c0,1.988,1.414,3.647,3.292,4.023 c-0.344,0.094-0.707,0.144-1.081,0.144c-0.264,0-0.521-0.026-0.772-0.074c0.522,1.63,2.038,2.816,3.833,2.85 c-1.404,1.1-3.174,1.756-5.096,1.756c-0.331,0-0.658-0.019-0.979-0.057c1.816,1.164,3.973,1.843,6.29,1.843 c7.547,0,11.675-6.252,11.675-11.675c0-0.178-0.004-0.355-0.012-0.531C20.985,7.47,21.68,6.747,22.23,5.924z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/vimeo.js b/gb-src/packages/block-library/src/social-link/icons/vimeo.js new file mode 100644 index 0000000000000..02940e55afc42 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/vimeo.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const VimeoIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M22.396,7.164c-0.093,2.026-1.507,4.799-4.245,8.32C15.322,19.161,12.928,21,10.97,21c-1.214,0-2.24-1.119-3.079-3.359 c-0.56-2.053-1.119-4.106-1.68-6.159C5.588,9.243,4.921,8.122,4.206,8.122c-0.156,0-0.701,0.328-1.634,0.98L1.594,7.841 c1.027-0.902,2.04-1.805,3.037-2.708C6.001,3.95,7.03,3.327,7.715,3.264c1.619-0.156,2.616,0.951,2.99,3.321 c0.404,2.557,0.685,4.147,0.841,4.769c0.467,2.121,0.981,3.181,1.542,3.181c0.435,0,1.09-0.688,1.963-2.065 c0.871-1.376,1.338-2.422,1.401-3.142c0.125-1.187-0.343-1.782-1.401-1.782c-0.498,0-1.012,0.115-1.541,0.341 c1.023-3.35,2.977-4.977,5.862-4.884C21.511,3.066,22.52,4.453,22.396,7.164z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/vk.js b/gb-src/packages/block-library/src/social-link/icons/vk.js new file mode 100644 index 0000000000000..14e186d920f13 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/vk.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const VkIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M22,7.1c0.2,0.4-0.4,1.5-1.6,3.1c-0.2,0.2-0.4,0.5-0.7,0.9c-0.5,0.7-0.9,1.1-0.9,1.4c-0.1,0.3-0.1,0.6,0.1,0.8 c0.1,0.1,0.4,0.4,0.8,0.9h0l0,0c1,0.9,1.6,1.7,2,2.3c0,0,0,0.1,0.1,0.1c0,0.1,0,0.1,0.1,0.3c0,0.1,0,0.2,0,0.4 c0,0.1-0.1,0.2-0.3,0.3c-0.1,0.1-0.4,0.1-0.6,0.1l-2.7,0c-0.2,0-0.4,0-0.6-0.1c-0.2-0.1-0.4-0.1-0.5-0.2l-0.2-0.1 c-0.2-0.1-0.5-0.4-0.7-0.7s-0.5-0.6-0.7-0.8c-0.2-0.2-0.4-0.4-0.6-0.6C14.8,15,14.6,15,14.4,15c0,0,0,0-0.1,0c0,0-0.1,0.1-0.2,0.2 c-0.1,0.1-0.2,0.2-0.2,0.3c-0.1,0.1-0.1,0.3-0.2,0.5c-0.1,0.2-0.1,0.5-0.1,0.8c0,0.1,0,0.2,0,0.3c0,0.1-0.1,0.2-0.1,0.2l0,0.1 c-0.1,0.1-0.3,0.2-0.6,0.2h-1.2c-0.5,0-1,0-1.5-0.2c-0.5-0.1-1-0.3-1.4-0.6s-0.7-0.5-1.1-0.7s-0.6-0.4-0.7-0.6l-0.3-0.3 c-0.1-0.1-0.2-0.2-0.3-0.3s-0.4-0.5-0.7-0.9s-0.7-1-1.1-1.6c-0.4-0.6-0.8-1.3-1.3-2.2C2.9,9.4,2.5,8.5,2.1,7.5C2,7.4,2,7.3,2,7.2 c0-0.1,0-0.1,0-0.2l0-0.1c0.1-0.1,0.3-0.2,0.6-0.2l2.9,0c0.1,0,0.2,0,0.2,0.1S5.9,6.9,5.9,7L6,7c0.1,0.1,0.2,0.2,0.3,0.3 C6.4,7.7,6.5,8,6.7,8.4C6.9,8.8,7,9,7.1,9.2l0.2,0.3c0.2,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.5,0.7s0.3,0.3,0.4,0.4 c0.1,0.1,0.3,0.1,0.4,0.1c0.1,0,0.2,0,0.3-0.1c0,0,0,0,0.1-0.1c0,0,0.1-0.1,0.1-0.2c0.1-0.1,0.1-0.3,0.1-0.5c0-0.2,0.1-0.5,0.1-0.8 c0-0.4,0-0.8,0-1.3c0-0.3,0-0.5-0.1-0.8c0-0.2-0.1-0.4-0.1-0.5L9.6,7.6C9.4,7.3,9.1,7.2,8.7,7.1C8.6,7.1,8.6,7,8.7,6.9 C8.9,6.7,9,6.6,9.1,6.5c0.4-0.2,1.2-0.3,2.5-0.3c0.6,0,1,0.1,1.4,0.1c0.1,0,0.3,0.1,0.3,0.1c0.1,0.1,0.2,0.1,0.2,0.3 c0,0.1,0.1,0.2,0.1,0.3s0,0.3,0,0.5c0,0.2,0,0.4,0,0.6c0,0.2,0,0.4,0,0.7c0,0.3,0,0.6,0,0.9c0,0.1,0,0.2,0,0.4c0,0.2,0,0.4,0,0.5 c0,0.1,0,0.3,0,0.4s0.1,0.3,0.1,0.4c0.1,0.1,0.1,0.2,0.2,0.3c0.1,0,0.1,0,0.2,0c0.1,0,0.2,0,0.3-0.1c0.1-0.1,0.2-0.2,0.4-0.4 s0.3-0.4,0.5-0.7c0.2-0.3,0.5-0.7,0.7-1.1c0.4-0.7,0.8-1.5,1.1-2.3c0-0.1,0.1-0.1,0.1-0.2c0-0.1,0.1-0.1,0.1-0.1l0,0l0.1,0 c0,0,0,0,0.1,0s0.2,0,0.2,0l3,0c0.3,0,0.5,0,0.7,0S21.9,7,21.9,7L22,7.1z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/wordpress.js b/gb-src/packages/block-library/src/social-link/icons/wordpress.js new file mode 100644 index 0000000000000..a6523d6738505 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/wordpress.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const WordPressIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <Path d="M12.158,12.786L9.46,20.625c0.806,0.237,1.657,0.366,2.54,0.366c1.047,0,2.051-0.181,2.986-0.51 c-0.024-0.038-0.046-0.079-0.065-0.124L12.158,12.786z M3.009,12c0,3.559,2.068,6.634,5.067,8.092L3.788,8.341 C3.289,9.459,3.009,10.696,3.009,12z M18.069,11.546c0-1.112-0.399-1.881-0.741-2.48c-0.456-0.741-0.883-1.368-0.883-2.109 c0-0.826,0.627-1.596,1.51-1.596c0.04,0,0.078,0.005,0.116,0.007C16.472,3.904,14.34,3.009,12,3.009 c-3.141,0-5.904,1.612-7.512,4.052c0.211,0.007,0.41,0.011,0.579,0.011c0.94,0,2.396-0.114,2.396-0.114 C7.947,6.93,8.004,7.642,7.52,7.699c0,0-0.487,0.057-1.029,0.085l3.274,9.739l1.968-5.901l-1.401-3.838 C9.848,7.756,9.389,7.699,9.389,7.699C8.904,7.67,8.961,6.93,9.446,6.958c0,0,1.484,0.114,2.368,0.114 c0.94,0,2.397-0.114,2.397-0.114c0.485-0.028,0.542,0.684,0.057,0.741c0,0-0.488,0.057-1.029,0.085l3.249,9.665l0.897-2.996 C17.841,13.284,18.069,12.316,18.069,11.546z M19.889,7.686c0.039,0.286,0.06,0.593,0.06,0.924c0,0.912-0.171,1.938-0.684,3.22 l-2.746,7.94c2.673-1.558,4.47-4.454,4.47-7.771C20.991,10.436,20.591,8.967,19.889,7.686z M12,22C6.486,22,2,17.514,2,12 C2,6.486,6.486,2,12,2c5.514,0,10,4.486,10,10C22,17.514,17.514,22,12,22z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/yelp.js b/gb-src/packages/block-library/src/social-link/icons/yelp.js new file mode 100644 index 0000000000000..953db89b6bbd7 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/yelp.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const YelpIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M12.271,16.718v1.417q-.011,3.257-.067,3.4a.707.707,0,0,1-.569.446,4.637,4.637,0,0,1-2.024-.424A4.609,4.609,0,0,1,7.8,20.565a.844.844,0,0,1-.19-.4.692.692,0,0,1,.044-.29,3.181,3.181,0,0,1,.379-.524q.335-.412,2.019-2.409.011,0,.669-.781a.757.757,0,0,1,.44-.274.965.965,0,0,1,.552.039.945.945,0,0,1,.418.324.732.732,0,0,1,.139.468Zm-1.662-2.8a.783.783,0,0,1-.58.781l-1.339.435q-3.067.981-3.257.981a.711.711,0,0,1-.6-.4,2.636,2.636,0,0,1-.19-.836,9.134,9.134,0,0,1,.011-1.857,3.559,3.559,0,0,1,.335-1.389.659.659,0,0,1,.625-.357,22.629,22.629,0,0,1,2.253.859q.781.324,1.283.524l.937.379a.771.771,0,0,1,.4.34A.982.982,0,0,1,10.609,13.917Zm9.213,3.313a4.467,4.467,0,0,1-1.021,1.8,4.559,4.559,0,0,1-1.512,1.417.671.671,0,0,1-.7-.078q-.156-.112-2.052-3.2l-.524-.859a.761.761,0,0,1-.128-.513.957.957,0,0,1,.217-.513.774.774,0,0,1,.926-.29q.011.011,1.327.446,2.264.736,2.7.887a2.082,2.082,0,0,1,.524.229.673.673,0,0,1,.245.68Zm-7.5-7.049q.056,1.137-.6,1.361-.647.19-1.272-.792L6.237,4.08a.7.7,0,0,1,.212-.691,5.788,5.788,0,0,1,2.314-1,5.928,5.928,0,0,1,2.5-.352.681.681,0,0,1,.547.5q.034.2.245,3.407T12.327,10.181Zm7.384,1.2a.679.679,0,0,1-.29.658q-.167.112-3.67.959-.747.167-1.015.257l.011-.022a.769.769,0,0,1-.513-.044.914.914,0,0,1-.413-.357.786.786,0,0,1,0-.971q.011-.011.836-1.137,1.394-1.908,1.673-2.275a2.423,2.423,0,0,1,.379-.435A.7.7,0,0,1,17.435,8a4.482,4.482,0,0,1,1.372,1.489,4.81,4.81,0,0,1,.9,1.868v.034Z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/icons/youtube.js b/gb-src/packages/block-library/src/social-link/icons/youtube.js new file mode 100644 index 0000000000000..0e822aa5b4f4d --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/icons/youtube.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ + +import { + Path, + SVG, +} from '@wordpress/components'; + +export const YoutubeIcon = ( ) => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M21.8,8.001c0,0-0.195-1.378-0.795-1.985c-0.76-0.797-1.613-0.801-2.004-0.847c-2.799-0.202-6.997-0.202-6.997-0.202 h-0.009c0,0-4.198,0-6.997,0.202C4.608,5.216,3.756,5.22,2.995,6.016C2.395,6.623,2.2,8.001,2.2,8.001S2,9.62,2,11.238v1.517 c0,1.618,0.2,3.237,0.2,3.237s0.195,1.378,0.795,1.985c0.761,0.797,1.76,0.771,2.205,0.855c1.6,0.153,6.8,0.201,6.8,0.201 s4.203-0.006,7.001-0.209c0.391-0.047,1.243-0.051,2.004-0.847c0.6-0.607,0.795-1.985,0.795-1.985s0.2-1.618,0.2-3.237v-1.517 C22,9.62,21.8,8.001,21.8,8.001z M9.935,14.594l-0.001-5.62l5.404,2.82L9.935,14.594z" /> + </SVG> +); diff --git a/gb-src/packages/block-library/src/social-link/index.js b/gb-src/packages/block-library/src/social-link/index.js new file mode 100644 index 0000000000000..692ff27f82fa5 --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/index.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import socialList from './social-list'; + +const commonAttributes = { + category: 'widgets', + parent: [ 'core/social-links' ], + supports: { + reusable: false, + html: false, + }, + edit, +}; + +// Create individual blocks out of each site in social-list.js +export const sites = Object.keys( socialList ).map( + ( site ) => { + const siteParams = socialList[ site ]; + return { + name: 'core/social-link-' + site, + settings: { + title: siteParams.name, + icon: siteParams.icon, + description: __( 'Link to ' + siteParams.name ), + ...commonAttributes, + attributes: { + url: { + type: 'string', + }, + site: { + type: 'string', + default: site, + }, + }, + }, + }; + } +); diff --git a/gb-src/packages/block-library/src/social-link/index.php b/gb-src/packages/block-library/src/social-link/index.php new file mode 100644 index 0000000000000..4d04c9cd1c12c --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/index.php @@ -0,0 +1,227 @@ +<?php +/** + * Server-side rendering of the `core/social-link` blocks. + * + * @package WordPress + */ + +/** + * Renders the `core/social-link` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Rendered HTML of the referenced block. + */ +function render_core_social_link( $attributes ) { + $site = ( isset( $attributes['site'] ) ) ? $attributes['site'] : 'Icon'; + $url = ( isset( $attributes['url'] ) ) ? $attributes['url'] : false; + + // Don't render a link if there is no URL set. + if ( ! $url ) { + return ''; + } + + $icon = core_social_link_get_icon( $site ); + return '<li class="wp-social-link wp-social-link-' . $site . '"><a href="' . esc_attr( $url ) . '"> ' . $icon . '</a></li>'; +} + +/** + * Registers the `core/social-link` blocks. + */ +function register_block_core_social_link() { + $sites = array( + 'amazon', + 'bandcamp', + 'behance', + 'chain', + 'codepen', + 'deviantart', + 'dribbble', + 'dropbox', + 'etsy', + 'facebook', + 'feed', + 'fivehundredpx', + 'flickr', + 'foursquare', + 'goodreads', + 'google', + 'github', + 'instagram', + 'lastfm', + 'linkedin', + 'mail', + 'mastodon', + 'meetup', + 'medium', + 'pinterest', + 'pocket', + 'reddit', + 'skype', + 'snapchat', + 'soundcloud', + 'spotify', + 'tumblr', + 'twitch', + 'twitter', + 'vimeo', + 'vk', + 'wordpress', + 'yelp', + 'youtube', + ); + + foreach ( $sites as $site ) { + register_block_type( + 'core/social-link-' . $site, + array( + 'attributes' => array( + 'url' => array( + 'type' => 'string', + ), + 'site' => array( + 'type' => 'string', + 'default' => $site, + ), + ), + 'render_callback' => 'render_core_social_link', + ) + ); + } +} +add_action( 'init', 'register_block_core_social_link' ); + + +/** + * Returns the SVG for social link. + * + * @param string $site The site icon. + * + * @return string SVG Element for site icon. + */ +function core_social_link_get_icon( $site ) { + switch ( $site ) { + + case 'fivehundredpx': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M6.94026,15.1412c.00437.01213.108.29862.168.44064a6.55008,6.55008,0,1,0,6.03191-9.09557,6.68654,6.68654,0,0,0-2.58357.51467A8.53914,8.53914,0,0,0,8.21268,8.61344L8.209,8.61725V3.22948l9.0504-.00008c.32934-.0036.32934-.46353.32934-.61466s0-.61091-.33035-.61467L7.47248,2a.43.43,0,0,0-.43131.42692v7.58355c0,.24466.30476.42131.58793.4819.553.11812.68074-.05864.81617-.2457l.018-.02481A10.52673,10.52673,0,0,1,9.32258,9.258a5.35268,5.35268,0,1,1,7.58985,7.54976,5.417,5.417,0,0,1-3.80867,1.56365,5.17483,5.17483,0,0,1-2.69822-.74478l.00342-4.61111a2.79372,2.79372,0,0,1,.71372-1.78792,2.61611,2.61611,0,0,1,1.98282-.89477,2.75683,2.75683,0,0,1,1.95525.79477,2.66867,2.66867,0,0,1,.79656,1.909,2.724,2.724,0,0,1-2.75849,2.748,4.94651,4.94651,0,0,1-.86254-.13719c-.31234-.093-.44519.34058-.48892.48349-.16811.54966.08453.65862.13687.67489a3.75751,3.75751,0,0,0,1.25234.18375,3.94634,3.94634,0,1,0-2.82444-6.742,3.67478,3.67478,0,0,0-1.13028,2.584l-.00041.02323c-.0035.11667-.00579,2.881-.00644,3.78811l-.00407-.00451a6.18521,6.18521,0,0,1-1.0851-1.86092c-.10544-.27856-.34358-.22925-.66857-.12917-.14192.04372-.57386.17677-.47833.489Zm4.65165-1.08338a.51346.51346,0,0,0,.19513.31818l.02276.022a.52945.52945,0,0,0,.3517.18416.24242.24242,0,0,0,.16577-.0611c.05473-.05082.67382-.67812.73287-.738l.69041.68819a.28978.28978,0,0,0,.21437.11032.53239.53239,0,0,0,.35708-.19486c.29792-.30419.14885-.46821.07676-.54751l-.69954-.69975.72952-.73469c.16-.17311.01874-.35708-.12218-.498-.20461-.20461-.402-.25742-.52855-.14083l-.7254.72665-.73354-.73375a.20128.20128,0,0,0-.14179-.05695.54135.54135,0,0,0-.34379.19648c-.22561.22555-.274.38149-.15656.5059l.73374.7315-.72942.73072A.26589.26589,0,0,0,11.59191,14.05782Zm1.59866-9.915A8.86081,8.86081,0,0,0,9.854,4.776a.26169.26169,0,0,0-.16938.22759.92978.92978,0,0,0,.08619.42094c.05682.14524.20779.531.50006.41955a8.40969,8.40969,0,0,1,2.91968-.55484,7.87875,7.87875,0,0,1,3.086.62286,8.61817,8.61817,0,0,1,2.30562,1.49315.2781.2781,0,0,0,.18318.07586c.15529,0,.30425-.15253.43167-.29551.21268-.23861.35873-.4369.1492-.63538a8.50425,8.50425,0,0,0-2.62312-1.694A9.0177,9.0177,0,0,0,13.19058,4.14283ZM19.50945,18.6236h0a.93171.93171,0,0,0-.36642-.25406.26589.26589,0,0,0-.27613.06613l-.06943.06929A7.90606,7.90606,0,0,1,7.60639,18.505a7.57284,7.57284,0,0,1-1.696-2.51537,8.58715,8.58715,0,0,1-.5147-1.77754l-.00871-.04864c-.04939-.25873-.28755-.27684-.62981-.22448-.14234.02178-.5755.088-.53426.39969l.001.00712a9.08807,9.08807,0,0,0,15.406,4.99094c.00193-.00192.04753-.04718.0725-.07436C19.79425,19.16234,19.87422,18.98728,19.50945,18.6236Z"></path></svg>'; + + case 'amazon': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M13.582,8.182C11.934,8.367,9.78,8.49,8.238,9.166c-1.781,0.769-3.03,2.337-3.03,4.644 c0,2.953,1.86,4.429,4.253,4.429c2.02,0,3.125-0.477,4.685-2.065c0.516,0.747,0.685,1.109,1.629,1.894 c0.212,0.114,0.483,0.103,0.672-0.066l0.006,0.006c0.567-0.505,1.599-1.401,2.18-1.888c0.231-0.188,0.19-0.496,0.009-0.754 c-0.52-0.718-1.072-1.303-1.072-2.634V8.305c0-1.876,0.133-3.599-1.249-4.891C15.23,2.369,13.422,2,12.04,2 C9.336,2,6.318,3.01,5.686,6.351C5.618,6.706,5.877,6.893,6.109,6.945l2.754,0.298C9.121,7.23,9.308,6.977,9.357,6.72 c0.236-1.151,1.2-1.706,2.284-1.706c0.584,0,1.249,0.215,1.595,0.738c0.398,0.584,0.346,1.384,0.346,2.061V8.182z M13.049,14.088 c-0.451,0.8-1.169,1.291-1.967,1.291c-1.09,0-1.728-0.83-1.728-2.061c0-2.42,2.171-2.86,4.227-2.86v0.615 C13.582,12.181,13.608,13.104,13.049,14.088z M20.683,19.339C18.329,21.076,14.917,22,11.979,22c-4.118,0-7.826-1.522-10.632-4.057 c-0.22-0.199-0.024-0.471,0.241-0.317c3.027,1.762,6.771,2.823,10.639,2.823c2.608,0,5.476-0.541,8.115-1.66 C20.739,18.62,21.072,19.051,20.683,19.339z M21.336,21.043c-0.194,0.163-0.379,0.076-0.293-0.139 c0.284-0.71,0.92-2.298,0.619-2.684c-0.301-0.386-1.99-0.183-2.749-0.092c-0.23,0.027-0.266-0.173-0.059-0.319 c1.348-0.946,3.555-0.673,3.811-0.356C22.925,17.773,22.599,19.986,21.336,21.043z"></path></svg>'; + + case 'bandcamp': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M15.27 17.289 3 17.289 8.73 6.711 21 6.711 15.27 17.289"></path></svg>'; + + case 'behance': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M7.799,5.698c0.589,0,1.12,0.051,1.606,0.156c0.482,0.102,0.894,0.273,1.241,0.507c0.344,0.235,0.612,0.546,0.804,0.938 c0.188,0.387,0.281,0.871,0.281,1.443c0,0.619-0.141,1.137-0.421,1.551c-0.284,0.413-0.7,0.751-1.255,1.014 c0.756,0.218,1.317,0.601,1.689,1.146c0.374,0.549,0.557,1.205,0.557,1.975c0,0.623-0.12,1.161-0.359,1.612 c-0.241,0.457-0.569,0.828-0.973,1.114c-0.408,0.288-0.876,0.5-1.399,0.637C9.052,17.931,8.514,18,7.963,18H2V5.698H7.799 M7.449,10.668c0.481,0,0.878-0.114,1.192-0.345c0.311-0.228,0.463-0.603,0.463-1.119c0-0.286-0.051-0.523-0.152-0.707 C8.848,8.315,8.711,8.171,8.536,8.07C8.362,7.966,8.166,7.894,7.94,7.854c-0.224-0.044-0.457-0.06-0.697-0.06H4.709v2.874H7.449z M7.6,15.905c0.267,0,0.521-0.024,0.759-0.077c0.243-0.053,0.457-0.137,0.637-0.261c0.182-0.12,0.332-0.283,0.441-0.491 C9.547,14.87,9.6,14.602,9.6,14.278c0-0.633-0.18-1.084-0.533-1.357c-0.356-0.27-0.83-0.404-1.413-0.404H4.709v3.388L7.6,15.905z M16.162,15.864c0.367,0.358,0.897,0.538,1.583,0.538c0.493,0,0.92-0.125,1.277-0.374c0.354-0.248,0.571-0.514,0.654-0.79h2.155 c-0.347,1.072-0.872,1.838-1.589,2.299C19.534,18,18.67,18.23,17.662,18.23c-0.701,0-1.332-0.113-1.899-0.337 c-0.567-0.227-1.041-0.544-1.439-0.958c-0.389-0.415-0.689-0.907-0.904-1.484c-0.213-0.574-0.32-1.21-0.32-1.899 c0-0.666,0.11-1.288,0.329-1.863c0.222-0.577,0.529-1.075,0.933-1.492c0.406-0.42,0.885-0.751,1.444-0.994 c0.558-0.241,1.175-0.363,1.857-0.363c0.754,0,1.414,0.145,1.98,0.44c0.563,0.291,1.026,0.686,1.389,1.181 c0.363,0.493,0.622,1.057,0.783,1.69c0.16,0.632,0.217,1.292,0.171,1.983h-6.428C15.557,14.84,15.795,15.506,16.162,15.864 M18.973,11.184c-0.291-0.321-0.783-0.496-1.384-0.496c-0.39,0-0.714,0.066-0.973,0.2c-0.254,0.132-0.461,0.297-0.621,0.491 c-0.157,0.197-0.265,0.405-0.328,0.628c-0.063,0.217-0.101,0.413-0.111,0.587h3.98C19.478,11.969,19.265,11.509,18.973,11.184z M15.057,7.738h4.985V6.524h-4.985L15.057,7.738z"></path></svg>'; + + case 'chain': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M19.647,16.706a1.134,1.134,0,0,0-.343-.833l-2.549-2.549a1.134,1.134,0,0,0-.833-.343,1.168,1.168,0,0,0-.883.392l.233.226q.2.189.264.264a2.922,2.922,0,0,1,.184.233.986.986,0,0,1,.159.312,1.242,1.242,0,0,1,.043.337,1.172,1.172,0,0,1-1.176,1.176,1.237,1.237,0,0,1-.337-.043,1,1,0,0,1-.312-.159,2.76,2.76,0,0,1-.233-.184q-.073-.068-.264-.264l-.226-.233a1.19,1.19,0,0,0-.4.895,1.134,1.134,0,0,0,.343.833L15.837,19.3a1.13,1.13,0,0,0,.833.331,1.18,1.18,0,0,0,.833-.318l1.8-1.789a1.12,1.12,0,0,0,.343-.821Zm-8.615-8.64a1.134,1.134,0,0,0-.343-.833L8.163,4.7a1.134,1.134,0,0,0-.833-.343,1.184,1.184,0,0,0-.833.331L4.7,6.473a1.12,1.12,0,0,0-.343.821,1.134,1.134,0,0,0,.343.833l2.549,2.549a1.13,1.13,0,0,0,.833.331,1.184,1.184,0,0,0,.883-.38L8.728,10.4q-.2-.189-.264-.264A2.922,2.922,0,0,1,8.28,9.9a.986.986,0,0,1-.159-.312,1.242,1.242,0,0,1-.043-.337A1.172,1.172,0,0,1,9.254,8.079a1.237,1.237,0,0,1,.337.043,1,1,0,0,1,.312.159,2.761,2.761,0,0,1,.233.184q.073.068.264.264l.226.233a1.19,1.19,0,0,0,.4-.895ZM22,16.706a3.343,3.343,0,0,1-1.042,2.488l-1.8,1.789a3.536,3.536,0,0,1-4.988-.025l-2.525-2.537a3.384,3.384,0,0,1-1.017-2.488,3.448,3.448,0,0,1,1.078-2.561l-1.078-1.078a3.434,3.434,0,0,1-2.549,1.078,3.4,3.4,0,0,1-2.5-1.029L3.029,9.794A3.4,3.4,0,0,1,2,7.294,3.343,3.343,0,0,1,3.042,4.806l1.8-1.789A3.384,3.384,0,0,1,7.331,2a3.357,3.357,0,0,1,2.5,1.042l2.525,2.537a3.384,3.384,0,0,1,1.017,2.488,3.448,3.448,0,0,1-1.078,2.561l1.078,1.078a3.551,3.551,0,0,1,5.049-.049l2.549,2.549A3.4,3.4,0,0,1,22,16.706Z"></path></svg>'; + + case 'codepen': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M22.016,8.84c-0.002-0.013-0.005-0.025-0.007-0.037c-0.005-0.025-0.008-0.048-0.015-0.072 c-0.003-0.015-0.01-0.028-0.013-0.042c-0.008-0.02-0.015-0.04-0.023-0.062c-0.007-0.015-0.013-0.028-0.02-0.042 c-0.008-0.02-0.018-0.037-0.03-0.057c-0.007-0.013-0.017-0.027-0.025-0.038c-0.012-0.018-0.023-0.035-0.035-0.052 c-0.01-0.013-0.02-0.025-0.03-0.037c-0.015-0.017-0.028-0.032-0.043-0.045c-0.01-0.012-0.022-0.023-0.035-0.035 c-0.015-0.015-0.032-0.028-0.048-0.04c-0.012-0.01-0.025-0.02-0.037-0.03c-0.005-0.003-0.01-0.008-0.015-0.012l-9.161-6.096 c-0.289-0.192-0.666-0.192-0.955,0L2.359,8.237C2.354,8.24,2.349,8.245,2.344,8.249L2.306,8.277 c-0.017,0.013-0.033,0.027-0.048,0.04C2.246,8.331,2.234,8.342,2.222,8.352c-0.015,0.015-0.028,0.03-0.042,0.047 c-0.012,0.013-0.022,0.023-0.03,0.037C2.139,8.453,2.125,8.471,2.115,8.488C2.107,8.501,2.099,8.514,2.09,8.526 C2.079,8.548,2.069,8.565,2.06,8.585C2.054,8.6,2.047,8.613,2.04,8.626C2.032,8.648,2.025,8.67,2.019,8.69 c-0.005,0.013-0.01,0.027-0.013,0.042C1.999,8.755,1.995,8.778,1.99,8.803C1.989,8.817,1.985,8.828,1.984,8.84 C1.978,8.879,1.975,8.915,1.975,8.954v6.093c0,0.037,0.003,0.075,0.008,0.112c0.002,0.012,0.005,0.025,0.007,0.038 c0.005,0.023,0.008,0.047,0.015,0.072c0.003,0.015,0.008,0.028,0.013,0.04c0.007,0.022,0.013,0.042,0.022,0.063 c0.007,0.015,0.013,0.028,0.02,0.04c0.008,0.02,0.018,0.038,0.03,0.058c0.007,0.013,0.015,0.027,0.025,0.038 c0.012,0.018,0.023,0.035,0.035,0.052c0.01,0.013,0.02,0.025,0.03,0.037c0.013,0.015,0.028,0.032,0.042,0.045 c0.012,0.012,0.023,0.023,0.035,0.035c0.015,0.013,0.032,0.028,0.048,0.04l0.038,0.03c0.005,0.003,0.01,0.007,0.013,0.01 l9.163,6.095C11.668,21.953,11.833,22,12,22c0.167,0,0.332-0.047,0.478-0.144l9.163-6.095l0.015-0.01 c0.013-0.01,0.027-0.02,0.037-0.03c0.018-0.013,0.035-0.028,0.048-0.04c0.013-0.012,0.025-0.023,0.035-0.035 c0.017-0.015,0.03-0.032,0.043-0.045c0.01-0.013,0.02-0.025,0.03-0.037c0.013-0.018,0.025-0.035,0.035-0.052 c0.008-0.013,0.018-0.027,0.025-0.038c0.012-0.02,0.022-0.038,0.03-0.058c0.007-0.013,0.013-0.027,0.02-0.04 c0.008-0.022,0.015-0.042,0.023-0.063c0.003-0.013,0.01-0.027,0.013-0.04c0.007-0.025,0.01-0.048,0.015-0.072 c0.002-0.013,0.005-0.027,0.007-0.037c0.003-0.042,0.007-0.079,0.007-0.117V8.954C22.025,8.915,22.022,8.879,22.016,8.84z M12.862,4.464l6.751,4.49l-3.016,2.013l-3.735-2.492V4.464z M11.138,4.464v4.009l-3.735,2.494L4.389,8.954L11.138,4.464z M3.699,10.562L5.853,12l-2.155,1.438V10.562z M11.138,19.536l-6.749-4.491l3.015-2.011l3.735,2.492V19.536z M12,14.035L8.953,12 L12,9.966L15.047,12L12,14.035z M12.862,19.536v-4.009l3.735-2.492l3.016,2.011L12.862,19.536z M20.303,13.438L18.147,12 l2.156-1.438L20.303,13.438z"></path></svg>'; + + case 'deviantart': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M 18.19 5.636 18.19 2 18.188 2 14.553 2 14.19 2.366 12.474 5.636 11.935 6 5.81 6 5.81 10.994 9.177 10.994 9.477 11.357 5.81 18.363 5.81 22 5.811 22 9.447 22 9.81 21.634 11.526 18.364 12.065 18 18.19 18 18.19 13.006 14.823 13.006 14.523 12.641 18.19 5.636z"></path></svg>'; + + case 'dribbble': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12,22C6.486,22,2,17.514,2,12S6.486,2,12,2c5.514,0,10,4.486,10,10S17.514,22,12,22z M20.434,13.369 c-0.292-0.092-2.644-0.794-5.32-0.365c1.117,3.07,1.572,5.57,1.659,6.09C18.689,17.798,20.053,15.745,20.434,13.369z M15.336,19.876c-0.127-0.749-0.623-3.361-1.822-6.477c-0.019,0.006-0.038,0.013-0.056,0.019c-4.818,1.679-6.547,5.02-6.701,5.334 c1.448,1.129,3.268,1.803,5.243,1.803C13.183,20.555,14.311,20.313,15.336,19.876z M5.654,17.724 c0.193-0.331,2.538-4.213,6.943-5.637c0.111-0.036,0.224-0.07,0.337-0.102c-0.214-0.485-0.448-0.971-0.692-1.45 c-4.266,1.277-8.405,1.223-8.778,1.216c-0.003,0.087-0.004,0.174-0.004,0.261C3.458,14.207,4.29,16.21,5.654,17.724z M3.639,10.264 c0.382,0.005,3.901,0.02,7.897-1.041c-1.415-2.516-2.942-4.631-3.167-4.94C5.979,5.41,4.193,7.613,3.639,10.264z M9.998,3.709 c0.236,0.316,1.787,2.429,3.187,5c3.037-1.138,4.323-2.867,4.477-3.085C16.154,4.286,14.17,3.471,12,3.471 C11.311,3.471,10.641,3.554,9.998,3.709z M18.612,6.612C18.432,6.855,17,8.69,13.842,9.979c0.199,0.407,0.389,0.821,0.567,1.237 c0.063,0.148,0.124,0.295,0.184,0.441c2.842-0.357,5.666,0.215,5.948,0.275C20.522,9.916,19.801,8.065,18.612,6.612z"></path></svg>'; + + case 'dropbox': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12,6.134L6.069,9.797L2,6.54l5.883-3.843L12,6.134z M2,13.054l5.883,3.843L12,13.459L6.069,9.797L2,13.054z M12,13.459 l4.116,3.439L22,13.054l-4.069-3.257L12,13.459z M22,6.54l-5.884-3.843L12,6.134l5.931,3.663L22,6.54z M12.011,14.2l-4.129,3.426 l-1.767-1.153v1.291l5.896,3.539l5.897-3.539v-1.291l-1.769,1.153L12.011,14.2z"></path></svg>'; + + case 'etsy': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M9.16033,4.038c0-.27174.02717-.43478.48913-.43478h6.22283c1.087,0,1.68478.92391,2.11957,2.663l.35326,1.38587h1.05978C19.59511,3.712,19.75815,2,19.75815,2s-2.663.29891-4.23913.29891h-7.962L3.29076,2.163v1.1413L4.731,3.57609c1.00543.19022,1.25.40761,1.33152,1.33152,0,0,.08152,2.71739.08152,7.20109s-.08152,7.17391-.08152,7.17391c0,.81522-.32609,1.11413-1.33152,1.30435l-1.44022.27174V22l4.2663-.13587h7.11957c1.60326,0,5.32609.13587,5.32609.13587.08152-.97826.625-5.40761.70652-5.89674H19.7038L18.644,18.52174c-.84239,1.90217-2.06522,2.038-3.42391,2.038H11.1712c-1.3587,0-2.01087-.54348-2.01087-1.712V12.65217s3.0163,0,3.99457.08152c.76087.05435,1.22283.27174,1.46739,1.33152l.32609,1.413h1.16848l-.08152-3.55978.163-3.587H15.02989l-.38043,1.57609c-.24457,1.03261-.40761,1.22283-1.46739,1.33152-1.38587.13587-4.02174.1087-4.02174.1087Z"></path></svg>'; + + case 'facebook': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.5 2 2 6.5 2 12c0 5 3.7 9.1 8.4 9.9v-7H7.9V12h2.5V9.8c0-2.5 1.5-3.9 3.8-3.9 1.1 0 2.2.2 2.2.2v2.5h-1.3c-1.2 0-1.6.8-1.6 1.6V12h2.8l-.4 2.9h-2.3v7C18.3 21.1 22 17 22 12c0-5.5-4.5-10-10-10z"></path></svg>'; + + case 'feed': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M2,8.667V12c5.515,0,10,4.485,10,10h3.333C15.333,14.637,9.363,8.667,2,8.667z M2,2v3.333 c9.19,0,16.667,7.477,16.667,16.667H22C22,10.955,13.045,2,2,2z M4.5,17C3.118,17,2,18.12,2,19.5S3.118,22,4.5,22S7,20.88,7,19.5 S5.882,17,4.5,17z"></path></svg>'; + + case 'flickr': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M6.5,7c-2.75,0-5,2.25-5,5s2.25,5,5,5s5-2.25,5-5S9.25,7,6.5,7z M17.5,7c-2.75,0-5,2.25-5,5s2.25,5,5,5s5-2.25,5-5 S20.25,7,17.5,7z"></path></svg>'; + + case 'foursquare': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M17.573,2c0,0-9.197,0-10.668,0S5,3.107,5,3.805s0,16.948,0,16.948c0,0.785,0.422,1.077,0.66,1.172 c0.238,0.097,0.892,0.177,1.285-0.275c0,0,5.035-5.843,5.122-5.93c0.132-0.132,0.132-0.132,0.262-0.132h3.26 c1.368,0,1.588-0.977,1.732-1.552c0.078-0.318,0.692-3.428,1.225-6.122l0.675-3.368C19.56,2.893,19.14,2,17.573,2z M16.495,7.22 c-0.053,0.252-0.372,0.518-0.665,0.518c-0.293,0-4.157,0-4.157,0c-0.467,0-0.802,0.318-0.802,0.787v0.508 c0,0.467,0.337,0.798,0.805,0.798c0,0,3.197,0,3.528,0s0.655,0.362,0.583,0.715c-0.072,0.353-0.407,2.102-0.448,2.295 c-0.04,0.193-0.262,0.523-0.655,0.523c-0.33,0-2.88,0-2.88,0c-0.523,0-0.683,0.068-1.033,0.503 c-0.35,0.437-3.505,4.223-3.505,4.223c-0.032,0.035-0.063,0.027-0.063-0.015V4.852c0-0.298,0.26-0.648,0.648-0.648 c0,0,8.228,0,8.562,0c0.315,0,0.61,0.297,0.528,0.683L16.495,7.22z"></path></svg>'; + + case 'goodreads': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M17.3,17.5c-0.2,0.8-0.5,1.4-1,1.9c-0.4,0.5-1,0.9-1.7,1.2C13.9,20.9,13.1,21,12,21c-0.6,0-1.3-0.1-1.9-0.2 c-0.6-0.1-1.1-0.4-1.6-0.7c-0.5-0.3-0.9-0.7-1.2-1.2c-0.3-0.5-0.5-1.1-0.5-1.7h1.5c0.1,0.5,0.2,0.9,0.5,1.2 c0.2,0.3,0.5,0.6,0.9,0.8c0.3,0.2,0.7,0.3,1.1,0.4c0.4,0.1,0.8,0.1,1.2,0.1c1.4,0,2.5-0.4,3.1-1.2c0.6-0.8,1-2,1-3.5v-1.7h0 c-0.4,0.8-0.9,1.4-1.6,1.9c-0.7,0.5-1.5,0.7-2.4,0.7c-1,0-1.9-0.2-2.6-0.5C8.7,15,8.1,14.5,7.7,14c-0.5-0.6-0.8-1.3-1-2.1 c-0.2-0.8-0.3-1.6-0.3-2.5c0-0.9,0.1-1.7,0.4-2.5c0.3-0.8,0.6-1.5,1.1-2c0.5-0.6,1.1-1,1.8-1.4C10.3,3.2,11.1,3,12,3 c0.5,0,0.9,0.1,1.3,0.2c0.4,0.1,0.8,0.3,1.1,0.5c0.3,0.2,0.6,0.5,0.9,0.8c0.3,0.3,0.5,0.6,0.6,1h0V3.4h1.5V15 C17.6,15.9,17.5,16.7,17.3,17.5z M13.8,14.1c0.5-0.3,0.9-0.7,1.3-1.1c0.3-0.5,0.6-1,0.8-1.6c0.2-0.6,0.3-1.2,0.3-1.9 c0-0.6-0.1-1.2-0.2-1.9c-0.1-0.6-0.4-1.2-0.7-1.7c-0.3-0.5-0.7-0.9-1.3-1.2c-0.5-0.3-1.1-0.5-1.9-0.5s-1.4,0.2-1.9,0.5 c-0.5,0.3-1,0.7-1.3,1.2C8.5,6.4,8.3,7,8.1,7.6C8,8.2,7.9,8.9,7.9,9.5c0,0.6,0.1,1.3,0.2,1.9C8.3,12,8.6,12.5,8.9,13 c0.3,0.5,0.8,0.8,1.3,1.1c0.5,0.3,1.1,0.4,1.9,0.4C12.7,14.5,13.3,14.4,13.8,14.1z"></path></svg>'; + + case 'google': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.02,10.18v3.72v0.01h5.51c-0.26,1.57-1.67,4.22-5.5,4.22c-3.31,0-6.01-2.75-6.01-6.12s2.7-6.12,6.01-6.12 c1.87,0,3.13,0.8,3.85,1.48l2.84-2.76C16.99,2.99,14.73,2,12.03,2c-5.52,0-10,4.48-10,10s4.48,10,10,10c5.77,0,9.6-4.06,9.6-9.77 c0-0.83-0.11-1.42-0.25-2.05H12.02z"></path></svg>'; + + case 'github': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12,2C6.477,2,2,6.477,2,12c0,4.419,2.865,8.166,6.839,9.489c0.5,0.09,0.682-0.218,0.682-0.484 c0-0.236-0.009-0.866-0.014-1.699c-2.782,0.602-3.369-1.34-3.369-1.34c-0.455-1.157-1.11-1.465-1.11-1.465 c-0.909-0.62,0.069-0.608,0.069-0.608c1.004,0.071,1.532,1.03,1.532,1.03c0.891,1.529,2.341,1.089,2.91,0.833 c0.091-0.647,0.349-1.086,0.635-1.337c-2.22-0.251-4.555-1.111-4.555-4.943c0-1.091,0.39-1.984,1.03-2.682 C6.546,8.54,6.202,7.524,6.746,6.148c0,0,0.84-0.269,2.75,1.025C10.295,6.95,11.15,6.84,12,6.836 c0.85,0.004,1.705,0.114,2.504,0.336c1.909-1.294,2.748-1.025,2.748-1.025c0.546,1.376,0.202,2.394,0.1,2.646 c0.64,0.699,1.026,1.591,1.026,2.682c0,3.841-2.337,4.687-4.565,4.935c0.359,0.307,0.679,0.917,0.679,1.852 c0,1.335-0.012,2.415-0.012,2.741c0,0.269,0.18,0.579,0.688,0.481C19.138,20.161,22,16.416,22,12C22,6.477,17.523,2,12,2z"></path></svg>'; + + case 'instagram': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12,4.622c2.403,0,2.688,0.009,3.637,0.052c0.877,0.04,1.354,0.187,1.671,0.31c0.42,0.163,0.72,0.358,1.035,0.673 c0.315,0.315,0.51,0.615,0.673,1.035c0.123,0.317,0.27,0.794,0.31,1.671c0.043,0.949,0.052,1.234,0.052,3.637 s-0.009,2.688-0.052,3.637c-0.04,0.877-0.187,1.354-0.31,1.671c-0.163,0.42-0.358,0.72-0.673,1.035 c-0.315,0.315-0.615,0.51-1.035,0.673c-0.317,0.123-0.794,0.27-1.671,0.31c-0.949,0.043-1.233,0.052-3.637,0.052 s-2.688-0.009-3.637-0.052c-0.877-0.04-1.354-0.187-1.671-0.31c-0.42-0.163-0.72-0.358-1.035-0.673 c-0.315-0.315-0.51-0.615-0.673-1.035c-0.123-0.317-0.27-0.794-0.31-1.671C4.631,14.688,4.622,14.403,4.622,12 s0.009-2.688,0.052-3.637c0.04-0.877,0.187-1.354,0.31-1.671c0.163-0.42,0.358-0.72,0.673-1.035 c0.315-0.315,0.615-0.51,1.035-0.673c0.317-0.123,0.794-0.27,1.671-0.31C9.312,4.631,9.597,4.622,12,4.622 M12,3 C9.556,3,9.249,3.01,8.289,3.054C7.331,3.098,6.677,3.25,6.105,3.472C5.513,3.702,5.011,4.01,4.511,4.511 c-0.5,0.5-0.808,1.002-1.038,1.594C3.25,6.677,3.098,7.331,3.054,8.289C3.01,9.249,3,9.556,3,12c0,2.444,0.01,2.751,0.054,3.711 c0.044,0.958,0.196,1.612,0.418,2.185c0.23,0.592,0.538,1.094,1.038,1.594c0.5,0.5,1.002,0.808,1.594,1.038 c0.572,0.222,1.227,0.375,2.185,0.418C9.249,20.99,9.556,21,12,21s2.751-0.01,3.711-0.054c0.958-0.044,1.612-0.196,2.185-0.418 c0.592-0.23,1.094-0.538,1.594-1.038c0.5-0.5,0.808-1.002,1.038-1.594c0.222-0.572,0.375-1.227,0.418-2.185 C20.99,14.751,21,14.444,21,12s-0.01-2.751-0.054-3.711c-0.044-0.958-0.196-1.612-0.418-2.185c-0.23-0.592-0.538-1.094-1.038-1.594 c-0.5-0.5-1.002-0.808-1.594-1.038c-0.572-0.222-1.227-0.375-2.185-0.418C14.751,3.01,14.444,3,12,3L12,3z M12,7.378 c-2.552,0-4.622,2.069-4.622,4.622S9.448,16.622,12,16.622s4.622-2.069,4.622-4.622S14.552,7.378,12,7.378z M12,15 c-1.657,0-3-1.343-3-3s1.343-3,3-3s3,1.343,3,3S13.657,15,12,15z M16.804,6.116c-0.596,0-1.08,0.484-1.08,1.08 s0.484,1.08,1.08,1.08c0.596,0,1.08-0.484,1.08-1.08S17.401,6.116,16.804,6.116z"></path></svg>'; + + case 'lastfm': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M10.5002,0 C4.7006,0 0,4.70109753 0,10.4998496 C0,16.2989526 4.7006,21 10.5002,21 C16.299,21 21,16.2989526 21,10.4998496 C21,4.70109753 16.299,0 10.5002,0 Z M14.69735,14.7204413 C13.3164,14.7151781 12.4346,14.0870017 11.83445,12.6859357 L11.6816001,12.3451305 L10.35405,9.31011397 C9.92709997,8.26875064 8.85260001,7.57120012 7.68010001,7.57120012 C6.06945001,7.57120012 4.75925001,8.88509738 4.75925001,10.5009524 C4.75925001,12.1164565 6.06945001,13.4303036 7.68010001,13.4303036 C8.77200001,13.4303036 9.76514999,12.827541 10.2719501,11.8567047 C10.2893,11.8235214 10.3239,11.8019673 10.36305,11.8038219 C10.4007,11.8053759 10.43535,11.8287847 10.4504,11.8631709 L10.98655,13.1045863 C11.0016,13.1389726 10.9956,13.17782 10.97225,13.2068931 C10.1605001,14.1995341 8.96020001,14.7683115 7.68010001,14.7683115 C5.33305,14.7683115 3.42340001,12.8535563 3.42340001,10.5009524 C3.42340001,8.14679459 5.33300001,6.23203946 7.68010001,6.23203946 C9.45720002,6.23203946 10.8909,7.19074535 11.6138,8.86359341 C11.6205501,8.88018505 12.3412,10.5707777 12.97445,12.0190621 C13.34865,12.8739575 13.64615,13.3959676 14.6288,13.4291508 C15.5663001,13.4612814 16.25375,12.9121534 16.25375,12.1484869 C16.25375,11.4691321 15.8320501,11.3003585 14.8803,10.98216 C13.2365,10.4397989 12.34495,9.88605929 12.34495,8.51817658 C12.34495,7.1809207 13.26665,6.31615054 14.692,6.31615054 C15.62875,6.31615054 16.3155,6.7286858 16.79215,7.5768142 C16.80495,7.60062396 16.8079001,7.62814302 16.8004001,7.65420843 C16.7929,7.68027384 16.7748,7.70212868 16.7507001,7.713808 L15.86145,8.16900031 C15.8178001,8.19200805 15.7643,8.17807308 15.73565,8.13847371 C15.43295,7.71345711 15.0956,7.52513451 14.6423,7.52513451 C14.05125,7.52513451 13.6220001,7.92899802 13.6220001,8.48649708 C13.6220001,9.17382194 14.1529001,9.34144259 15.0339,9.61923972 C15.14915,9.65578139 15.26955,9.69397731 15.39385,9.73432853 C16.7763,10.1865133 17.57675,10.7311301 17.57675,12.1836251 C17.57685,13.629654 16.3389,14.7204413 14.69735,14.7204413 Z"></path></svg>'; + + case 'linkedin': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M19.7,3H4.3C3.582,3,3,3.582,3,4.3v15.4C3,20.418,3.582,21,4.3,21h15.4c0.718,0,1.3-0.582,1.3-1.3V4.3 C21,3.582,20.418,3,19.7,3z M8.339,18.338H5.667v-8.59h2.672V18.338z M7.004,8.574c-0.857,0-1.549-0.694-1.549-1.548 c0-0.855,0.691-1.548,1.549-1.548c0.854,0,1.547,0.694,1.547,1.548C8.551,7.881,7.858,8.574,7.004,8.574z M18.339,18.338h-2.669 v-4.177c0-0.996-0.017-2.278-1.387-2.278c-1.389,0-1.601,1.086-1.601,2.206v4.249h-2.667v-8.59h2.559v1.174h0.037 c0.356-0.675,1.227-1.387,2.526-1.387c2.703,0,3.203,1.779,3.203,4.092V18.338z"></path></svg>'; + + case 'mail': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M20,4H4C2.895,4,2,4.895,2,6v12c0,1.105,0.895,2,2,2h16c1.105,0,2-0.895,2-2V6C22,4.895,21.105,4,20,4z M20,8.236l-8,4.882 L4,8.236V6h16V8.236z"></path></svg>'; + + case 'mastodon': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z"/></svg>'; + + case 'meetup': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M19.24775,14.722a3.57032,3.57032,0,0,1-2.94457,3.52073,3.61886,3.61886,0,0,1-.64652.05634c-.07314-.0008-.10187.02846-.12507.09547A2.38881,2.38881,0,0,1,13.49453,20.094a2.33092,2.33092,0,0,1-1.827-.50716.13635.13635,0,0,0-.19878-.00408,3.191,3.191,0,0,1-2.104.60248,3.26309,3.26309,0,0,1-3.00324-2.71993,2.19076,2.19076,0,0,1-.03512-.30865c-.00156-.08579-.03413-.1189-.11608-.13493a2.86421,2.86421,0,0,1-1.23189-.56111,2.945,2.945,0,0,1-1.166-2.05749,2.97484,2.97484,0,0,1,.87524-2.50774.112.112,0,0,0,.02091-.16107,2.7213,2.7213,0,0,1-.36648-1.48A2.81256,2.81256,0,0,1,6.57673,7.58838a.35764.35764,0,0,0,.28869-.22819,4.2208,4.2208,0,0,1,6.02892-1.90111.25161.25161,0,0,0,.22023.0243,3.65608,3.65608,0,0,1,3.76031.90678A3.57244,3.57244,0,0,1,17.95918,8.626a2.97339,2.97339,0,0,1,.01829.57356.10637.10637,0,0,0,.0853.12792,1.97669,1.97669,0,0,1,1.27939,1.33733,2.00266,2.00266,0,0,1-.57112,2.12652c-.05284.05166-.04168.08328-.01173.13489A3.51189,3.51189,0,0,1,19.24775,14.722Zm-6.35959-.27836a1.6984,1.6984,0,0,0,1.14556,1.61113,3.82039,3.82039,0,0,0,1.036.17935,1.46888,1.46888,0,0,0,.73509-.12255.44082.44082,0,0,0,.26057-.44274.45312.45312,0,0,0-.29211-.43375.97191.97191,0,0,0-.20678-.063c-.21326-.03806-.42754-.0701-.63973-.11215a.54787.54787,0,0,1-.50172-.60926,2.75864,2.75864,0,0,1,.1773-.901c.1763-.535.414-1.045.64183-1.55913A12.686,12.686,0,0,0,15.85,10.47863a1.58461,1.58461,0,0,0,.04861-.87208,1.04531,1.04531,0,0,0-.85432-.83981,1.60658,1.60658,0,0,0-1.23654.16594.27593.27593,0,0,1-.36286-.03413c-.085-.0747-.16594-.15379-.24918-.23055a.98682.98682,0,0,0-1.33577-.04933,6.1468,6.1468,0,0,1-.4989.41615.47762.47762,0,0,1-.51535.03566c-.17448-.09307-.35512-.175-.53531-.25665a1.74949,1.74949,0,0,0-.56476-.2016,1.69943,1.69943,0,0,0-1.61654.91787,8.05815,8.05815,0,0,0-.32952.80126c-.45471,1.2557-.82507,2.53825-1.20838,3.81639a1.24151,1.24151,0,0,0,.51532,1.44389,1.42659,1.42659,0,0,0,1.22008.17166,1.09728,1.09728,0,0,0,.66994-.69764c.44145-1.04111.839-2.09989,1.25981-3.14926.11581-.28876.22792-.57874.35078-.86438a.44548.44548,0,0,1,.69189-.19539.50521.50521,0,0,1,.15044.43836,1.75625,1.75625,0,0,1-.14731.50453c-.27379.69219-.55265,1.38236-.82766,2.074a2.0836,2.0836,0,0,0-.14038.42876.50719.50719,0,0,0,.27082.57722.87236.87236,0,0,0,.66145.02739.99137.99137,0,0,0,.53406-.532q.61571-1.20914,1.228-2.42031.28423-.55863.57585-1.1133a.87189.87189,0,0,1,.29055-.35253.34987.34987,0,0,1,.37634-.01265.30291.30291,0,0,1,.12434.31459.56716.56716,0,0,1-.04655.1915c-.05318.12739-.10286.25669-.16183.38156-.34118.71775-.68754,1.43273-1.02568,2.152A2.00213,2.00213,0,0,0,12.88816,14.44366Zm4.78568,5.28972a.88573.88573,0,0,0-1.77139.00465.8857.8857,0,0,0,1.77139-.00465Zm-14.83838-7.296a.84329.84329,0,1,0,.00827-1.68655.8433.8433,0,0,0-.00827,1.68655Zm10.366-9.43673a.83506.83506,0,1,0-.0091,1.67.83505.83505,0,0,0,.0091-1.67Zm6.85014,5.22a.71651.71651,0,0,0-1.433.0093.71656.71656,0,0,0,1.433-.0093ZM5.37528,6.17908A.63823.63823,0,1,0,6.015,5.54483.62292.62292,0,0,0,5.37528,6.17908Zm6.68214,14.80843a.54949.54949,0,1,0-.55052.541A.54556.54556,0,0,0,12.05742,20.98752Zm8.53235-8.49689a.54777.54777,0,0,0-.54027.54023.53327.53327,0,0,0,.532.52293.51548.51548,0,0,0,.53272-.5237A.53187.53187,0,0,0,20.58977,12.49063ZM7.82846,2.4715a.44927.44927,0,1,0,.44484.44766A.43821.43821,0,0,0,7.82846,2.4715Zm13.775,7.60492a.41186.41186,0,0,0-.40065.39623.40178.40178,0,0,0,.40168.40168A.38994.38994,0,0,0,22,10.48172.39946.39946,0,0,0,21.60349,10.07642ZM5.79193,17.96207a.40469.40469,0,0,0-.397-.39646.399.399,0,0,0-.396.405.39234.39234,0,0,0,.39939.389A.39857.39857,0,0,0,5.79193,17.96207Z"></path></svg>'; + + case 'medium': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M20.962,7.257l-5.457,8.867l-3.923-6.375l3.126-5.08c0.112-0.182,0.319-0.286,0.527-0.286c0.05,0,0.1,0.008,0.149,0.02 c0.039,0.01,0.078,0.023,0.114,0.041l5.43,2.715l0.006,0.003c0.004,0.002,0.007,0.006,0.011,0.008 C20.971,7.191,20.98,7.227,20.962,7.257z M9.86,8.592v5.783l5.14,2.57L9.86,8.592z M15.772,17.331l4.231,2.115 C20.554,19.721,21,19.529,21,19.016V8.835L15.772,17.331z M8.968,7.178L3.665,4.527C3.569,4.479,3.478,4.456,3.395,4.456 C3.163,4.456,3,4.636,3,4.938v11.45c0,0.306,0.224,0.669,0.498,0.806l4.671,2.335c0.12,0.06,0.234,0.088,0.337,0.088 c0.29,0,0.494-0.225,0.494-0.602V7.231C9,7.208,8.988,7.188,8.968,7.178z"></path></svg>'; + + case 'pinterest': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.289,2C6.617,2,3.606,5.648,3.606,9.622c0,1.846,1.025,4.146,2.666,4.878c0.25,0.111,0.381,0.063,0.439-0.169 c0.044-0.175,0.267-1.029,0.365-1.428c0.032-0.128,0.017-0.237-0.091-0.362C6.445,11.911,6.01,10.75,6.01,9.668 c0-2.777,2.194-5.464,5.933-5.464c3.23,0,5.49,2.108,5.49,5.122c0,3.407-1.794,5.768-4.13,5.768c-1.291,0-2.257-1.021-1.948-2.277 c0.372-1.495,1.089-3.112,1.089-4.191c0-0.967-0.542-1.775-1.663-1.775c-1.319,0-2.379,1.309-2.379,3.059 c0,1.115,0.394,1.869,0.394,1.869s-1.302,5.279-1.54,6.261c-0.405,1.666,0.053,4.368,0.094,4.604 c0.021,0.126,0.167,0.169,0.25,0.063c0.129-0.165,1.699-2.419,2.142-4.051c0.158-0.59,0.817-2.995,0.817-2.995 c0.43,0.784,1.681,1.446,3.013,1.446c3.963,0,6.822-3.494,6.822-7.833C20.394,5.112,16.849,2,12.289,2"></path></svg>'; + + case 'pocket': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M21.927,4.194C21.667,3.48,20.982,3,20.222,3h-0.01h-1.721H3.839C3.092,3,2.411,3.47,2.145,4.17 C2.066,4.378,2.026,4.594,2.026,4.814v6.035l0.069,1.2c0.29,2.73,1.707,5.115,3.899,6.778c0.039,0.03,0.079,0.059,0.119,0.089 l0.025,0.018c1.175,0.859,2.491,1.441,3.91,1.727c0.655,0.132,1.325,0.2,1.991,0.2c0.615,0,1.232-0.057,1.839-0.17 c0.073-0.014,0.145-0.028,0.219-0.044c0.02-0.004,0.042-0.012,0.064-0.023c1.359-0.297,2.621-0.864,3.753-1.691l0.025-0.018 c0.04-0.029,0.08-0.058,0.119-0.089c2.192-1.664,3.609-4.049,3.898-6.778l0.069-1.2V4.814C22.026,4.605,22,4.398,21.927,4.194z M17.692,10.481l-4.704,4.512c-0.266,0.254-0.608,0.382-0.949,0.382c-0.342,0-0.684-0.128-0.949-0.382l-4.705-4.512 C5.838,9.957,5.82,9.089,6.344,8.542c0.524-0.547,1.392-0.565,1.939-0.04l3.756,3.601l3.755-3.601 c0.547-0.524,1.415-0.506,1.939,0.04C18.256,9.089,18.238,9.956,17.692,10.481z"></path></svg>'; + + case 'reddit': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M22,11.816c0-1.256-1.021-2.277-2.277-2.277c-0.593,0-1.122,0.24-1.526,0.614c-1.481-0.965-3.455-1.594-5.647-1.69 l1.171-3.702l3.18,0.748c0.008,1.028,0.846,1.862,1.876,1.862c1.035,0,1.877-0.842,1.877-1.878c0-1.035-0.842-1.877-1.877-1.877 c-0.769,0-1.431,0.466-1.72,1.13l-3.508-0.826c-0.203-0.047-0.399,0.067-0.46,0.261l-1.35,4.268 c-2.316,0.038-4.411,0.67-5.97,1.671C5.368,9.765,4.853,9.539,4.277,9.539C3.021,9.539,2,10.56,2,11.816 c0,0.814,0.433,1.523,1.078,1.925c-0.037,0.221-0.061,0.444-0.061,0.672c0,3.292,4.011,5.97,8.941,5.97s8.941-2.678,8.941-5.97 c0-0.214-0.02-0.424-0.053-0.632C21.533,13.39,22,12.661,22,11.816z M18.776,4.394c0.606,0,1.1,0.493,1.1,1.1s-0.493,1.1-1.1,1.1 s-1.1-0.494-1.1-1.1S18.169,4.394,18.776,4.394z M2.777,11.816c0-0.827,0.672-1.5,1.499-1.5c0.313,0,0.598,0.103,0.838,0.269 c-0.851,0.676-1.477,1.479-1.812,2.36C2.983,12.672,2.777,12.27,2.777,11.816z M11.959,19.606c-4.501,0-8.164-2.329-8.164-5.193 S7.457,9.22,11.959,9.22s8.164,2.329,8.164,5.193S16.46,19.606,11.959,19.606z M20.636,13.001c-0.326-0.89-0.948-1.701-1.797-2.384 c0.248-0.186,0.55-0.301,0.883-0.301c0.827,0,1.5,0.673,1.5,1.5C21.223,12.299,20.992,12.727,20.636,13.001z M8.996,14.704 c-0.76,0-1.397-0.616-1.397-1.376c0-0.76,0.637-1.397,1.397-1.397c0.76,0,1.376,0.637,1.376,1.397 C10.372,14.088,9.756,14.704,8.996,14.704z M16.401,13.328c0,0.76-0.616,1.376-1.376,1.376c-0.76,0-1.399-0.616-1.399-1.376 c0-0.76,0.639-1.397,1.399-1.397C15.785,11.931,16.401,12.568,16.401,13.328z M15.229,16.708c0.152,0.152,0.152,0.398,0,0.55 c-0.674,0.674-1.727,1.002-3.219,1.002c-0.004,0-0.007-0.002-0.011-0.002c-0.004,0-0.007,0.002-0.011,0.002 c-1.492,0-2.544-0.328-3.218-1.002c-0.152-0.152-0.152-0.398,0-0.55c0.152-0.152,0.399-0.151,0.55,0 c0.521,0.521,1.394,0.775,2.669,0.775c0.004,0,0.007,0.002,0.011,0.002c0.004,0,0.007-0.002,0.011-0.002 c1.275,0,2.148-0.253,2.669-0.775C14.831,16.556,15.078,16.556,15.229,16.708z"></path></svg>'; + + case 'skype': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M10.113,2.699c0.033-0.006,0.067-0.013,0.1-0.02c0.033,0.017,0.066,0.033,0.098,0.051L10.113,2.699z M2.72,10.223 c-0.006,0.034-0.011,0.069-0.017,0.103c0.018,0.032,0.033,0.064,0.051,0.095L2.72,10.223z M21.275,13.771 c0.007-0.035,0.011-0.071,0.018-0.106c-0.018-0.031-0.033-0.064-0.052-0.095L21.275,13.771z M13.563,21.199 c0.032,0.019,0.065,0.035,0.096,0.053c0.036-0.006,0.071-0.011,0.105-0.017L13.563,21.199z M22,16.386 c0,1.494-0.581,2.898-1.637,3.953c-1.056,1.057-2.459,1.637-3.953,1.637c-0.967,0-1.914-0.251-2.75-0.725 c0.036-0.006,0.071-0.011,0.105-0.017l-0.202-0.035c0.032,0.019,0.065,0.035,0.096,0.053c-0.543,0.096-1.099,0.147-1.654,0.147 c-1.275,0-2.512-0.25-3.676-0.743c-1.125-0.474-2.135-1.156-3.002-2.023c-0.867-0.867-1.548-1.877-2.023-3.002 c-0.493-1.164-0.743-2.401-0.743-3.676c0-0.546,0.049-1.093,0.142-1.628c0.018,0.032,0.033,0.064,0.051,0.095L2.72,10.223 c-0.006,0.034-0.011,0.069-0.017,0.103C2.244,9.5,2,8.566,2,7.615c0-1.493,0.582-2.898,1.637-3.953 c1.056-1.056,2.46-1.638,3.953-1.638c0.915,0,1.818,0.228,2.622,0.655c-0.033,0.007-0.067,0.013-0.1,0.02l0.199,0.031 c-0.032-0.018-0.066-0.034-0.098-0.051c0.002,0,0.003-0.001,0.004-0.001c0.586-0.112,1.187-0.169,1.788-0.169 c1.275,0,2.512,0.249,3.676,0.742c1.124,0.476,2.135,1.156,3.002,2.024c0.868,0.867,1.548,1.877,2.024,3.002 c0.493,1.164,0.743,2.401,0.743,3.676c0,0.575-0.054,1.15-0.157,1.712c-0.018-0.031-0.033-0.064-0.052-0.095l0.034,0.201 c0.007-0.035,0.011-0.071,0.018-0.106C21.754,14.494,22,15.432,22,16.386z M16.817,14.138c0-1.331-0.613-2.743-3.033-3.282 l-2.209-0.49c-0.84-0.192-1.807-0.444-1.807-1.237c0-0.794,0.679-1.348,1.903-1.348c2.468,0,2.243,1.696,3.468,1.696 c0.645,0,1.209-0.379,1.209-1.031c0-1.521-2.435-2.663-4.5-2.663c-2.242,0-4.63,0.952-4.63,3.488c0,1.221,0.436,2.521,2.839,3.123 l2.984,0.745c0.903,0.223,1.129,0.731,1.129,1.189c0,0.762-0.758,1.507-2.129,1.507c-2.679,0-2.307-2.062-3.743-2.062 c-0.645,0-1.113,0.444-1.113,1.078c0,1.236,1.501,2.886,4.856,2.886C15.236,17.737,16.817,16.199,16.817,14.138z"></path></svg>'; + + case 'snapchat': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.065,2a5.526,5.526,0,0,1,3.132.892A5.854,5.854,0,0,1,17.326,5.4a5.821,5.821,0,0,1,.351,2.33q0,.612-.117,2.487a.809.809,0,0,0,.365.091,1.93,1.93,0,0,0,.664-.176,1.93,1.93,0,0,1,.664-.176,1.3,1.3,0,0,1,.729.234.7.7,0,0,1,.351.6.839.839,0,0,1-.41.7,2.732,2.732,0,0,1-.9.41,3.192,3.192,0,0,0-.9.378.728.728,0,0,0-.41.618,1.575,1.575,0,0,0,.156.56,6.9,6.9,0,0,0,1.334,1.953,5.6,5.6,0,0,0,1.881,1.315,5.875,5.875,0,0,0,1.042.3.42.42,0,0,1,.365.456q0,.911-2.852,1.341a1.379,1.379,0,0,0-.143.507,1.8,1.8,0,0,1-.182.605.451.451,0,0,1-.429.241,5.878,5.878,0,0,1-.807-.085,5.917,5.917,0,0,0-.833-.085,4.217,4.217,0,0,0-.807.065,2.42,2.42,0,0,0-.82.293,6.682,6.682,0,0,0-.755.5q-.351.267-.755.527a3.886,3.886,0,0,1-.989.436A4.471,4.471,0,0,1,11.831,22a4.307,4.307,0,0,1-1.256-.176,3.784,3.784,0,0,1-.976-.436q-.4-.26-.749-.527a6.682,6.682,0,0,0-.755-.5,2.422,2.422,0,0,0-.807-.293,4.432,4.432,0,0,0-.82-.065,5.089,5.089,0,0,0-.853.1,5,5,0,0,1-.762.1.474.474,0,0,1-.456-.241,1.819,1.819,0,0,1-.182-.618,1.411,1.411,0,0,0-.143-.521q-2.852-.429-2.852-1.341a.42.42,0,0,1,.365-.456,5.793,5.793,0,0,0,1.042-.3,5.524,5.524,0,0,0,1.881-1.315,6.789,6.789,0,0,0,1.334-1.953A1.575,1.575,0,0,0,6,12.9a.728.728,0,0,0-.41-.618,3.323,3.323,0,0,0-.9-.384,2.912,2.912,0,0,1-.9-.41.814.814,0,0,1-.41-.684.71.71,0,0,1,.338-.593,1.208,1.208,0,0,1,.716-.241,1.976,1.976,0,0,1,.625.169,2.008,2.008,0,0,0,.69.169.919.919,0,0,0,.416-.091q-.117-1.849-.117-2.474A5.861,5.861,0,0,1,6.385,5.4,5.516,5.516,0,0,1,8.625,2.819,7.075,7.075,0,0,1,12.062,2Z"></path></svg>'; + + case 'soundcloud': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M8.9,16.1L9,14L8.9,9.5c0-0.1,0-0.1-0.1-0.1c0,0-0.1-0.1-0.1-0.1c-0.1,0-0.1,0-0.1,0.1c0,0-0.1,0.1-0.1,0.1L8.3,14l0.1,2.1 c0,0.1,0,0.1,0.1,0.1c0,0,0.1,0.1,0.1,0.1C8.8,16.3,8.9,16.3,8.9,16.1z M11.4,15.9l0.1-1.8L11.4,9c0-0.1,0-0.2-0.1-0.2 c0,0-0.1,0-0.1,0s-0.1,0-0.1,0c-0.1,0-0.1,0.1-0.1,0.2l0,0.1l-0.1,5c0,0,0,0.7,0.1,2v0c0,0.1,0,0.1,0.1,0.1c0.1,0.1,0.1,0.1,0.2,0.1 c0.1,0,0.1,0,0.2-0.1c0.1,0,0.1-0.1,0.1-0.2L11.4,15.9z M2.4,12.9L2.5,14l-0.2,1.1c0,0.1,0,0.1-0.1,0.1c0,0-0.1,0-0.1-0.1L2.1,14 l0.1-1.1C2.2,12.9,2.3,12.9,2.4,12.9C2.3,12.9,2.4,12.9,2.4,12.9z M3.1,12.2L3.3,14l-0.2,1.8c0,0.1,0,0.1-0.1,0.1 c-0.1,0-0.1,0-0.1-0.1L2.8,14L3,12.2C3,12.2,3,12.2,3.1,12.2C3.1,12.2,3.1,12.2,3.1,12.2z M3.9,11.9L4.1,14l-0.2,2.1 c0,0.1,0,0.1-0.1,0.1c-0.1,0-0.1,0-0.1-0.1L3.5,14l0.2-2.1c0-0.1,0-0.1,0.1-0.1C3.9,11.8,3.9,11.8,3.9,11.9z M4.7,11.9L4.9,14 l-0.2,2.1c0,0.1-0.1,0.1-0.1,0.1c-0.1,0-0.1,0-0.1-0.1L4.3,14l0.2-2.2c0-0.1,0-0.1,0.1-0.1C4.7,11.7,4.7,11.8,4.7,11.9z M5.6,12 l0.2,2l-0.2,2.1c0,0.1-0.1,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0-0.1,0-0.1L5.1,14l0.2-2c0,0,0-0.1,0-0.1s0.1,0,0.1,0 C5.5,11.9,5.5,11.9,5.6,12L5.6,12z M6.4,10.7L6.6,14l-0.2,2.1c0,0,0,0.1,0,0.1c0,0-0.1,0-0.1,0c-0.1,0-0.1-0.1-0.2-0.2L5.9,14 l0.2-3.3c0-0.1,0.1-0.2,0.2-0.2c0,0,0.1,0,0.1,0C6.4,10.7,6.4,10.7,6.4,10.7z M7.2,10l0.2,4.1l-0.2,2.1c0,0,0,0.1,0,0.1 c0,0-0.1,0-0.1,0c-0.1,0-0.2-0.1-0.2-0.2l-0.1-2.1L6.8,10c0-0.1,0.1-0.2,0.2-0.2c0,0,0.1,0,0.1,0S7.2,9.9,7.2,10z M8,9.6L8.2,14 L8,16.1c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L7.5,14l0.1-4.4c0-0.1,0-0.1,0.1-0.1c0,0,0.1-0.1,0.1-0.1c0.1,0,0.1,0,0.1,0.1 C8,9.6,8,9.6,8,9.6z M11.4,16.1L11.4,16.1L11.4,16.1z M9.7,9.6L9.8,14l-0.1,2.1c0,0.1,0,0.1-0.1,0.2s-0.1,0.1-0.2,0.1 c-0.1,0-0.1,0-0.1-0.1s-0.1-0.1-0.1-0.2L9.2,14l0.1-4.4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2,0.1S9.7,9.5,9.7,9.6 L9.7,9.6z M10.6,9.8l0.1,4.3l-0.1,2c0,0.1,0,0.1-0.1,0.2c0,0-0.1,0.1-0.2,0.1c-0.1,0-0.1,0-0.2-0.1c0,0-0.1-0.1-0.1-0.2L10,14 l0.1-4.3c0-0.1,0-0.1,0.1-0.2c0,0,0.1-0.1,0.2-0.1c0.1,0,0.1,0,0.2,0.1S10.6,9.7,10.6,9.8z M12.4,14l-0.1,2c0,0.1,0,0.1-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.1,0-0.1,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2l-0.1-1l-0.1-1l0.1-5.5v0c0-0.1,0-0.2,0.1-0.2 c0.1,0,0.1-0.1,0.2-0.1c0,0,0.1,0,0.1,0c0.1,0,0.1,0.1,0.1,0.2L12.4,14z M22.1,13.9c0,0.7-0.2,1.3-0.7,1.7c-0.5,0.5-1.1,0.7-1.7,0.7 h-6.8c-0.1,0-0.1,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2V8.2c0-0.1,0.1-0.2,0.2-0.3c0.5-0.2,1-0.3,1.6-0.3c1.1,0,2.1,0.4,2.9,1.1 c0.8,0.8,1.3,1.7,1.4,2.8c0.3-0.1,0.6-0.2,1-0.2c0.7,0,1.3,0.2,1.7,0.7C21.8,12.6,22.1,13.2,22.1,13.9L22.1,13.9z"></path></svg>'; + + case 'spotify': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12,2C6.477,2,2,6.477,2,12c0,5.523,4.477,10,10,10c5.523,0,10-4.477,10-10C22,6.477,17.523,2,12,2 M16.586,16.424 c-0.18,0.295-0.563,0.387-0.857,0.207c-2.348-1.435-5.304-1.76-8.785-0.964c-0.335,0.077-0.67-0.133-0.746-0.469 c-0.077-0.335,0.132-0.67,0.469-0.746c3.809-0.871,7.077-0.496,9.713,1.115C16.673,15.746,16.766,16.13,16.586,16.424 M17.81,13.7 c-0.226,0.367-0.706,0.482-1.072,0.257c-2.687-1.652-6.785-2.131-9.965-1.166C6.36,12.917,5.925,12.684,5.8,12.273 C5.675,11.86,5.908,11.425,6.32,11.3c3.632-1.102,8.147-0.568,11.234,1.328C17.92,12.854,18.035,13.335,17.81,13.7 M17.915,10.865 c-3.223-1.914-8.54-2.09-11.618-1.156C5.804,9.859,5.281,9.58,5.131,9.086C4.982,8.591,5.26,8.069,5.755,7.919 c3.532-1.072,9.404-0.865,13.115,1.338c0.445,0.264,0.59,0.838,0.327,1.282C18.933,10.983,18.359,11.129,17.915,10.865"></path></svg>'; + + case 'tumblr': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M16.749,17.396c-0.357,0.17-1.041,0.319-1.551,0.332c-1.539,0.041-1.837-1.081-1.85-1.896V9.847h3.861V6.937h-3.847V2.039 c0,0-2.77,0-2.817,0c-0.046,0-0.127,0.041-0.138,0.144c-0.165,1.499-0.867,4.13-3.783,5.181v2.484h1.945v6.282 c0,2.151,1.587,5.206,5.775,5.135c1.413-0.024,2.982-0.616,3.329-1.126L16.749,17.396z"></path></svg>'; + + case 'twitch': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M16.499,8.089h-1.636v4.91h1.636V8.089z M12,8.089h-1.637v4.91H12V8.089z M4.228,3.178L3,6.451v13.092h4.499V22h2.456 l2.454-2.456h3.681L21,14.636V3.178H4.228z M19.364,13.816l-2.864,2.865H12l-2.453,2.453V16.68H5.863V4.814h13.501V13.816z"></path></svg>'; + + case 'twitter': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M22.23,5.924c-0.736,0.326-1.527,0.547-2.357,0.646c0.847-0.508,1.498-1.312,1.804-2.27 c-0.793,0.47-1.671,0.812-2.606,0.996C18.324,4.498,17.257,4,16.077,4c-2.266,0-4.103,1.837-4.103,4.103 c0,0.322,0.036,0.635,0.106,0.935C8.67,8.867,5.647,7.234,3.623,4.751C3.27,5.357,3.067,6.062,3.067,6.814 c0,1.424,0.724,2.679,1.825,3.415c-0.673-0.021-1.305-0.206-1.859-0.513c0,0.017,0,0.034,0,0.052c0,1.988,1.414,3.647,3.292,4.023 c-0.344,0.094-0.707,0.144-1.081,0.144c-0.264,0-0.521-0.026-0.772-0.074c0.522,1.63,2.038,2.816,3.833,2.85 c-1.404,1.1-3.174,1.756-5.096,1.756c-0.331,0-0.658-0.019-0.979-0.057c1.816,1.164,3.973,1.843,6.29,1.843 c7.547,0,11.675-6.252,11.675-11.675c0-0.178-0.004-0.355-0.012-0.531C20.985,7.47,21.68,6.747,22.23,5.924z"></path></svg>'; + + case 'vimeo': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M22.396,7.164c-0.093,2.026-1.507,4.799-4.245,8.32C15.322,19.161,12.928,21,10.97,21c-1.214,0-2.24-1.119-3.079-3.359 c-0.56-2.053-1.119-4.106-1.68-6.159C5.588,9.243,4.921,8.122,4.206,8.122c-0.156,0-0.701,0.328-1.634,0.98L1.594,7.841 c1.027-0.902,2.04-1.805,3.037-2.708C6.001,3.95,7.03,3.327,7.715,3.264c1.619-0.156,2.616,0.951,2.99,3.321 c0.404,2.557,0.685,4.147,0.841,4.769c0.467,2.121,0.981,3.181,1.542,3.181c0.435,0,1.09-0.688,1.963-2.065 c0.871-1.376,1.338-2.422,1.401-3.142c0.125-1.187-0.343-1.782-1.401-1.782c-0.498,0-1.012,0.115-1.541,0.341 c1.023-3.35,2.977-4.977,5.862-4.884C21.511,3.066,22.52,4.453,22.396,7.164z"></path></svg>'; + + case 'vk': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M22,7.1c0.2,0.4-0.4,1.5-1.6,3.1c-0.2,0.2-0.4,0.5-0.7,0.9c-0.5,0.7-0.9,1.1-0.9,1.4c-0.1,0.3-0.1,0.6,0.1,0.8 c0.1,0.1,0.4,0.4,0.8,0.9h0l0,0c1,0.9,1.6,1.7,2,2.3c0,0,0,0.1,0.1,0.1c0,0.1,0,0.1,0.1,0.3c0,0.1,0,0.2,0,0.4 c0,0.1-0.1,0.2-0.3,0.3c-0.1,0.1-0.4,0.1-0.6,0.1l-2.7,0c-0.2,0-0.4,0-0.6-0.1c-0.2-0.1-0.4-0.1-0.5-0.2l-0.2-0.1 c-0.2-0.1-0.5-0.4-0.7-0.7s-0.5-0.6-0.7-0.8c-0.2-0.2-0.4-0.4-0.6-0.6C14.8,15,14.6,15,14.4,15c0,0,0,0-0.1,0c0,0-0.1,0.1-0.2,0.2 c-0.1,0.1-0.2,0.2-0.2,0.3c-0.1,0.1-0.1,0.3-0.2,0.5c-0.1,0.2-0.1,0.5-0.1,0.8c0,0.1,0,0.2,0,0.3c0,0.1-0.1,0.2-0.1,0.2l0,0.1 c-0.1,0.1-0.3,0.2-0.6,0.2h-1.2c-0.5,0-1,0-1.5-0.2c-0.5-0.1-1-0.3-1.4-0.6s-0.7-0.5-1.1-0.7s-0.6-0.4-0.7-0.6l-0.3-0.3 c-0.1-0.1-0.2-0.2-0.3-0.3s-0.4-0.5-0.7-0.9s-0.7-1-1.1-1.6c-0.4-0.6-0.8-1.3-1.3-2.2C2.9,9.4,2.5,8.5,2.1,7.5C2,7.4,2,7.3,2,7.2 c0-0.1,0-0.1,0-0.2l0-0.1c0.1-0.1,0.3-0.2,0.6-0.2l2.9,0c0.1,0,0.2,0,0.2,0.1S5.9,6.9,5.9,7L6,7c0.1,0.1,0.2,0.2,0.3,0.3 C6.4,7.7,6.5,8,6.7,8.4C6.9,8.8,7,9,7.1,9.2l0.2,0.3c0.2,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.5,0.7s0.3,0.3,0.4,0.4 c0.1,0.1,0.3,0.1,0.4,0.1c0.1,0,0.2,0,0.3-0.1c0,0,0,0,0.1-0.1c0,0,0.1-0.1,0.1-0.2c0.1-0.1,0.1-0.3,0.1-0.5c0-0.2,0.1-0.5,0.1-0.8 c0-0.4,0-0.8,0-1.3c0-0.3,0-0.5-0.1-0.8c0-0.2-0.1-0.4-0.1-0.5L9.6,7.6C9.4,7.3,9.1,7.2,8.7,7.1C8.6,7.1,8.6,7,8.7,6.9 C8.9,6.7,9,6.6,9.1,6.5c0.4-0.2,1.2-0.3,2.5-0.3c0.6,0,1,0.1,1.4,0.1c0.1,0,0.3,0.1,0.3,0.1c0.1,0.1,0.2,0.1,0.2,0.3 c0,0.1,0.1,0.2,0.1,0.3s0,0.3,0,0.5c0,0.2,0,0.4,0,0.6c0,0.2,0,0.4,0,0.7c0,0.3,0,0.6,0,0.9c0,0.1,0,0.2,0,0.4c0,0.2,0,0.4,0,0.5 c0,0.1,0,0.3,0,0.4s0.1,0.3,0.1,0.4c0.1,0.1,0.1,0.2,0.2,0.3c0.1,0,0.1,0,0.2,0c0.1,0,0.2,0,0.3-0.1c0.1-0.1,0.2-0.2,0.4-0.4 s0.3-0.4,0.5-0.7c0.2-0.3,0.5-0.7,0.7-1.1c0.4-0.7,0.8-1.5,1.1-2.3c0-0.1,0.1-0.1,0.1-0.2c0-0.1,0.1-0.1,0.1-0.1l0,0l0.1,0 c0,0,0,0,0.1,0s0.2,0,0.2,0l3,0c0.3,0,0.5,0,0.7,0S21.9,7,21.9,7L22,7.1z"></path></svg>'; + + // phpcs:disable WordPress.WP.CapitalPDangit.Misspelled + case 'wordpress': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.158,12.786L9.46,20.625c0.806,0.237,1.657,0.366,2.54,0.366c1.047,0,2.051-0.181,2.986-0.51 c-0.024-0.038-0.046-0.079-0.065-0.124L12.158,12.786z M3.009,12c0,3.559,2.068,6.634,5.067,8.092L3.788,8.341 C3.289,9.459,3.009,10.696,3.009,12z M18.069,11.546c0-1.112-0.399-1.881-0.741-2.48c-0.456-0.741-0.883-1.368-0.883-2.109 c0-0.826,0.627-1.596,1.51-1.596c0.04,0,0.078,0.005,0.116,0.007C16.472,3.904,14.34,3.009,12,3.009 c-3.141,0-5.904,1.612-7.512,4.052c0.211,0.007,0.41,0.011,0.579,0.011c0.94,0,2.396-0.114,2.396-0.114 C7.947,6.93,8.004,7.642,7.52,7.699c0,0-0.487,0.057-1.029,0.085l3.274,9.739l1.968-5.901l-1.401-3.838 C9.848,7.756,9.389,7.699,9.389,7.699C8.904,7.67,8.961,6.93,9.446,6.958c0,0,1.484,0.114,2.368,0.114 c0.94,0,2.397-0.114,2.397-0.114c0.485-0.028,0.542,0.684,0.057,0.741c0,0-0.488,0.057-1.029,0.085l3.249,9.665l0.897-2.996 C17.841,13.284,18.069,12.316,18.069,11.546z M19.889,7.686c0.039,0.286,0.06,0.593,0.06,0.924c0,0.912-0.171,1.938-0.684,3.22 l-2.746,7.94c2.673-1.558,4.47-4.454,4.47-7.771C20.991,10.436,20.591,8.967,19.889,7.686z M12,22C6.486,22,2,17.514,2,12 C2,6.486,6.486,2,12,2c5.514,0,10,4.486,10,10C22,17.514,17.514,22,12,22z"></path></svg>'; + + case 'yelp': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.271,16.718v1.417q-.011,3.257-.067,3.4a.707.707,0,0,1-.569.446,4.637,4.637,0,0,1-2.024-.424A4.609,4.609,0,0,1,7.8,20.565a.844.844,0,0,1-.19-.4.692.692,0,0,1,.044-.29,3.181,3.181,0,0,1,.379-.524q.335-.412,2.019-2.409.011,0,.669-.781a.757.757,0,0,1,.44-.274.965.965,0,0,1,.552.039.945.945,0,0,1,.418.324.732.732,0,0,1,.139.468Zm-1.662-2.8a.783.783,0,0,1-.58.781l-1.339.435q-3.067.981-3.257.981a.711.711,0,0,1-.6-.4,2.636,2.636,0,0,1-.19-.836,9.134,9.134,0,0,1,.011-1.857,3.559,3.559,0,0,1,.335-1.389.659.659,0,0,1,.625-.357,22.629,22.629,0,0,1,2.253.859q.781.324,1.283.524l.937.379a.771.771,0,0,1,.4.34A.982.982,0,0,1,10.609,13.917Zm9.213,3.313a4.467,4.467,0,0,1-1.021,1.8,4.559,4.559,0,0,1-1.512,1.417.671.671,0,0,1-.7-.078q-.156-.112-2.052-3.2l-.524-.859a.761.761,0,0,1-.128-.513.957.957,0,0,1,.217-.513.774.774,0,0,1,.926-.29q.011.011,1.327.446,2.264.736,2.7.887a2.082,2.082,0,0,1,.524.229.673.673,0,0,1,.245.68Zm-7.5-7.049q.056,1.137-.6,1.361-.647.19-1.272-.792L6.237,4.08a.7.7,0,0,1,.212-.691,5.788,5.788,0,0,1,2.314-1,5.928,5.928,0,0,1,2.5-.352.681.681,0,0,1,.547.5q.034.2.245,3.407T12.327,10.181Zm7.384,1.2a.679.679,0,0,1-.29.658q-.167.112-3.67.959-.747.167-1.015.257l.011-.022a.769.769,0,0,1-.513-.044.914.914,0,0,1-.413-.357.786.786,0,0,1,0-.971q.011-.011.836-1.137,1.394-1.908,1.673-2.275a2.423,2.423,0,0,1,.379-.435A.7.7,0,0,1,17.435,8a4.482,4.482,0,0,1,1.372,1.489,4.81,4.81,0,0,1,.9,1.868v.034Z"></path></svg>'; + + case 'youtube': + return '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M21.8,8.001c0,0-0.195-1.378-0.795-1.985c-0.76-0.797-1.613-0.801-2.004-0.847c-2.799-0.202-6.997-0.202-6.997-0.202 h-0.009c0,0-4.198,0-6.997,0.202C4.608,5.216,3.756,5.22,2.995,6.016C2.395,6.623,2.2,8.001,2.2,8.001S2,9.62,2,11.238v1.517 c0,1.618,0.2,3.237,0.2,3.237s0.195,1.378,0.795,1.985c0.761,0.797,1.76,0.771,2.205,0.855c1.6,0.153,6.8,0.201,6.8,0.201 s4.203-0.006,7.001-0.209c0.391-0.047,1.243-0.051,2.004-0.847c0.6-0.607,0.795-1.985,0.795-1.985s0.2-1.618,0.2-3.237v-1.517 C22,9.62,21.8,8.001,21.8,8.001z M9.935,14.594l-0.001-5.62l5.404,2.82L9.935,14.594z"></path></svg>'; + + case 'share': + default: + return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect x="0" fill="none" width="20" height="20"/><g><path d="M14.5 12c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3c0-.24.03-.46.09-.69l-4.38-2.3c-.55.61-1.33.99-2.21.99-1.66 0-3-1.34-3-3s1.34-3 3-3c.88 0 1.66.39 2.21.99l4.38-2.3c-.06-.23-.09-.45-.09-.69 0-1.66 1.34-3 3-3s3 1.34 3 3-1.34 3-3 3c-.88 0-1.66-.39-2.21-.99l-4.38 2.3c.06.23.09.45.09.69s-.03.46-.09.69l4.38 2.3c.55-.61 1.33-.99 2.21-.99z"/></g></svg>'; + } +} diff --git a/gb-src/packages/block-library/src/social-link/social-list.js b/gb-src/packages/block-library/src/social-link/social-list.js new file mode 100644 index 0000000000000..98c8120449c5a --- /dev/null +++ b/gb-src/packages/block-library/src/social-link/social-list.js @@ -0,0 +1,209 @@ +/** + * Internal dependencies + */ +import { + AmazonIcon, + BandcampIcon, + BehanceIcon, + ChainIcon, + CodepenIcon, + DeviantartIcon, + DribbbleIcon, + DropboxIcon, + EtsyIcon, + FacebookIcon, + FeedIcon, + FivehundredpxIcon, + FlickrIcon, + FoursquareIcon, + GoodreadsIcon, + GoogleIcon, + GithubIcon, + InstagramIcon, + LastfmIcon, + LinkedinIcon, + MailIcon, + MastodonIcon, + MeetupIcon, + MediumIcon, + PinterestIcon, + PocketIcon, + RedditIcon, + SkypeIcon, + SnapchatIcon, + SoundcloudIcon, + SpotifyIcon, + TumblrIcon, + TwitchIcon, + TwitterIcon, + VimeoIcon, + VkIcon, + WordPressIcon, + YelpIcon, + YoutubeIcon, +} from './icons'; + +const socialList = { + fivehundredpx: { + name: '500px', + icon: FivehundredpxIcon, + }, + amazon: { + name: 'Amazon', + icon: AmazonIcon, + }, + bandcamp: { + name: 'Bandcamp', + icon: BandcampIcon, + }, + behance: { + name: 'Behance', + icon: BehanceIcon, + }, + chain: { + name: 'Link', + icon: ChainIcon, + }, + codepen: { + name: 'CodePen', + icon: CodepenIcon, + }, + deviantart: { + name: 'DeviantArt', + icon: DeviantartIcon, + }, + dribbble: { + name: 'Dribbble', + icon: DribbbleIcon, + }, + dropbox: { + name: 'Dropbox', + icon: DropboxIcon, + }, + etsy: { + name: 'Etsy', + icon: EtsyIcon, + }, + facebook: { + name: 'Facebook', + icon: FacebookIcon, + }, + feed: { + name: 'RSS Feed', + icon: FeedIcon, + }, + flickr: { + name: 'Flickr', + icon: FlickrIcon, + }, + foursquare: { + name: 'Foursquare', + icon: FoursquareIcon, + }, + goodreads: { + name: 'Goodreads', + icon: GoodreadsIcon, + }, + google: { + name: 'Google', + icon: GoogleIcon, + }, + github: { + name: 'Github', + icon: GithubIcon, + }, + instagram: { + name: 'Instagram', + icon: InstagramIcon, + }, + lastfm: { + name: 'Last.fm', + icon: LastfmIcon, + }, + linkedin: { + name: 'Linkedin', + icon: LinkedinIcon, + }, + mail: { + name: 'Mail', + icon: MailIcon, + }, + mastodon: { + name: 'Mastodon', + icon: MastodonIcon, + }, + meetup: { + name: 'Meetup', + icon: MeetupIcon, + }, + medium: { + name: 'Medium', + icon: MediumIcon, + }, + pinterest: { + name: 'Pinterest', + icon: PinterestIcon, + }, + pocket: { + name: 'Pocket', + icon: PocketIcon, + }, + reddit: { + name: 'Reddit', + icon: RedditIcon, + }, + skype: { + name: 'Skype', + icon: SkypeIcon, + }, + snapchat: { + name: 'Snapchat', + icon: SnapchatIcon, + }, + soundcloud: { + name: 'Soundcloud', + icon: SoundcloudIcon, + }, + spotify: { + name: 'Spotify', + icon: SpotifyIcon, + }, + tumblr: { + name: 'Tumblr', + icon: TumblrIcon, + }, + twitch: { + name: 'Twitch', + icon: TwitchIcon, + }, + twitter: { + name: 'Twitter', + icon: TwitterIcon, + }, + vimeo: { + name: 'Vimeo', + icon: VimeoIcon, + }, + vk: { + name: 'VK', + icon: VkIcon, + }, + wordpress: { + name: 'WordPress', + icon: WordPressIcon, + }, + yelp: { + name: 'Yelp', + icon: YelpIcon, + }, + youtube: { + name: 'YouTube', + icon: YoutubeIcon, + }, +}; + +export default socialList; + +export const getIconBySite = ( site ) => { + return socialList[ site ].icon; +}; diff --git a/gb-src/packages/block-library/src/social-links/block.json b/gb-src/packages/block-library/src/social-links/block.json new file mode 100644 index 0000000000000..6d25baacb1a85 --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/block.json @@ -0,0 +1,6 @@ +{ + "name": "core/social-links", + "category": "widgets", + "icon": "share", + "attributes": {} +} diff --git a/gb-src/packages/block-library/src/social-links/edit.js b/gb-src/packages/block-library/src/social-links/edit.js new file mode 100644 index 0000000000000..5b063ef476ec4 --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/edit.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ + +import { + InnerBlocks, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import socialList from '../social-link/social-list'; + +const ALLOWED_BLOCKS = Object.keys( socialList ).map( ( site ) => { + return 'core/social-link-' + site; +} ); + +// Template contains the links that show when start. +const TEMPLATE = [ + [ 'core/social-link-wordpress', { url: 'https://wordpress.org' } ], + [ 'core/social-link-facebook' ], + [ 'core/social-link-twitter' ], + [ 'core/social-link-instagram' ], + [ 'core/social-link-linkedin' ], + [ 'core/social-link-youtube' ], +]; + +export const SocialLinksEdit = function( { className } ) { + return ( + <div className={ className }> + <InnerBlocks + allowedBlocks={ ALLOWED_BLOCKS } + templateLock={ false } + template={ TEMPLATE } + /> + </div> + ); +}; + +export default SocialLinksEdit; diff --git a/gb-src/packages/block-library/src/social-links/editor.scss b/gb-src/packages/block-library/src/social-links/editor.scss new file mode 100644 index 0000000000000..9996e30b4e12a --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/editor.scss @@ -0,0 +1,145 @@ +// Editor specific styles for Social Links. +.wp-block-social-links { + div.editor-url-input { + display: inline-block; + margin-left: $grid-size; + } + + .editor-block-list__layout { + display: flex; + justify-content: flex-start; + } +} + +// Reduce the paddings, margins, and UI of inner-blocks. +// @todo: eventually we may add a feature that lets a parent container absorb the block UI of a child block. +// When that happens, leverage that instead of the following overrides. +[data-type="core/social-links"] { + // 1. Reset margins on immediate innerblocks container. + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout { + margin-left: 0; + margin-right: 0; + } + + // 2. Remove paddings on subsequent immediate children. + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout > .wp-block { + width: auto; + padding-left: 0; + padding-right: 0; + } + + // 3. Remove margins on subsequent Edit container. + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout > .wp-block > .block-editor-block-list__block-edit { + margin-left: 0; + margin-right: 0; + } + + // 4. Minimize the block outlines. + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout > .wp-block > .block-editor-block-list__block-edit::before { + border-right: none; + border-top: none; + border-bottom: none; + } + + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout > .wp-block.is-hovered:not(.is-navigate-mode) > .block-editor-block-list__block-edit::before { + box-shadow: none; + } + + // 5. Remove the dashed outlines for child blocks. + &.is-hovered .wp-block-social-links .block-editor-block-list__block-edit::before, + &.is-selected .wp-block-social-links .block-editor-block-list__block-edit::before, + &.has-child-selected .wp-block-social-links .block-editor-block-list__block-edit::before { + border-color: transparent !important; // !important used to keep the selector from growing any more complex. + } + + // 6. Remove vertical margins on subsequent block container. + .wp-block-social-links > .block-editor-inner-blocks > .block-editor-block-list__layout > .wp-block > .block-editor-block-list__block-edit > [data-block] { + margin-top: 0; + margin-bottom: 0; + } + + // Hide the breadcrumb. + // Hide the mover. + // Hide the sibling inserter. + .wp-block-social-links .block-editor-block-list__insertion-point, + .wp-block-social-links .block-editor-block-list__breadcrumb, + .wp-block-social-links .block-editor-block-mover.block-editor-block-mover { // Needs specificity. + display: none; + } +} + +// Polish the Appender. +.wp-block-social-links .block-list-appender { + margin: 0; + + .block-editor-button-block-appender { + padding: $grid-size; + outline: none; + background: none; + } +} + +// Center flex items. This has an equivalent in style.scss. +[data-type="core/social-links"][data-align="center"] .wp-block-social-links { + justify-content: center; +} + +// Improve the preview. +// @todo: Look at improving the preview component to make this unnecessary. +.block-editor-block-preview__content { + + // This ensures buttons in preview are fully opaque despite being disabled. + .wp-social-link:disabled { + opacity: 1; + } + + // This ensures the preview is of a good size. + [data-type="core/social-links"] { + width: auto !important; + display: inline-block; + } +} + +// Selected/unselected states. +// Unselected block is preview, selected has additional options. +[data-type="core/social-links"]:not(.is-selected):not(.has-child-selected) .wp-block-social-links { + min-height: 36px; // This height matches the height of the buttons and ensures an empty block doesn't collapse. +} + +// Disappear but show on hover or nav mode focus. +[data-type="core/social-links"] .wp-social-link__is-incomplete { + transition: transform 0.1s ease; + transform-origin: center center; +} + +[data-type="core/social-links"]:not(.is-selected):not(.has-child-selected) { + .wp-social-link__is-incomplete { + opacity: 0; + transform: scale(0); + width: 0; + padding: 0; + margin-right: 0; + } +} + +// Unconfigured placeholder links are semitransparent. +.wp-social-link.wp-social-link__is-incomplete { + opacity: 0.5; +} + +.wp-block-social-links .is-selected .wp-social-link__is-incomplete, +.wp-social-link.wp-social-link__is-incomplete:hover, +.wp-social-link.wp-social-link__is-incomplete:focus { + opacity: 1; +} + + +// Focus styles for the button inside the child block. +// The child block itself has a more generic focus style, see line 55. +[data-type="core/social-links"] .wp-social-link:focus { // This needs specificity. + opacity: 1; + box-shadow: 0 0 0 2px $white, 0 0 0 4px $blue-medium-focus; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; +} diff --git a/gb-src/packages/block-library/src/social-links/index.js b/gb-src/packages/block-library/src/social-links/index.js new file mode 100644 index 0000000000000..651ef3ca9f1e0 --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Social links' ), + description: __( 'Create a block of links to your social media or external sites' ), + supports: { + align: [ 'left', 'center', 'right' ], + }, + example: { + innerBlocks: [ + { name: 'core/social-link-wordpress', attributes: { url: 'https://wordpress.org' } }, + { name: 'core/social-link-facebook', attributes: { url: 'https://www.facebook.com/WordPress/' } }, + { name: 'core/social-link-twitter', attributes: { url: 'https://twitter.com/WordPress' } }, + ], + }, + styles: [ + { name: 'default', label: __( 'Default' ), isDefault: true }, + { name: 'logos-only', label: __( 'Logos Only' ) }, + { name: 'pill-shape', label: __( 'Pill Shape' ) }, + ], + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/social-links/save.js b/gb-src/packages/block-library/src/social-links/save.js new file mode 100644 index 0000000000000..d21ee51216daa --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/save.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save( { className } ) { + return ( + <ul className={ className }> + <InnerBlocks.Content /> + </ul> + ); +} diff --git a/gb-src/packages/block-library/src/social-links/style.scss b/gb-src/packages/block-library/src/social-links/style.scss new file mode 100644 index 0000000000000..7ef0111887593 --- /dev/null +++ b/gb-src/packages/block-library/src/social-links/style.scss @@ -0,0 +1,407 @@ +.wp-block-social-links { + display: flex; + justify-content: flex-start; + padding-left: 0; + padding-right: 0; + // Some themes give all <ul> default margin instead of padding. + margin-left: 0; +} + +.wp-social-link { + display: block; + width: 36px; + height: 36px; + border-radius: 36px; // This makes it pill-shaped instead of oval, in cases where the image fed is not perfectly sized. + margin-right: 8px; + transition: transform 0.1s ease; + + a { + padding: 6px; + display: block; + line-height: 0; + transition: transform 0.1s ease; + } + + a, + a:hover, + a:active, + a:visited, + svg { + color: currentColor; + fill: currentColor; + } + + &:hover { + transform: scale(1.1); + } +} + +// Center flex items. This has an equivalent in editor.scss. +// It also needs to override some of the default classes usually applied to the centering class. +.wp-block-social-links.aligncenter { + justify-content: center; + display: flex; +} + + +// Provide colors for a range of icons. +.wp-block-social-links:not(.is-style-logos-only) { + // Generic items such as mail, feed, etc. + .wp-social-link { + background-color: #f0f0f0; + color: #444; + } + + .wp-social-link-amazon { + background-color: #f90; + color: #fff; + } + + .wp-social-link-bandcamp { + background-color: #1ea0c3; + color: #fff; + } + + .wp-social-link-behance { + background-color: #0757fe; + color: #fff; + } + + .wp-social-link-codepen { + background-color: #1e1f26; + color: #fff; + } + + .wp-social-link-deviantart { + background-color: #02e49b; + color: #fff; + } + + .wp-social-link-dribbble { + background-color: #e94c89; + color: #fff; + } + + .wp-social-link-dropbox { + background-color: #4280ff; + color: #fff; + } + + .wp-social-link-etsy { + background-color: #f45800; + color: #fff; + } + + .wp-social-link-facebook { + background-color: #1977f2; + color: #fff; + } + + .wp-social-link-fivehundredpx { + background-color: #000; + color: #fff; + } + + .wp-social-link-flickr { + background-color: #0461dd; + color: #fff; + } + + .wp-social-link-foursquare { + background-color: #e65678; + color: #fff; + } + + .wp-social-link-github { + background-color: #24292d; + color: #fff; + } + + .wp-social-link-goodreads { + background-color: #eceadd; + color: #382110; + } + + .wp-social-link-google { + background-color: #ea4434; + color: #fff; + } + + .wp-social-link-instagram { + background-color: #f00075; + color: #fff; + } + + .wp-social-link-lastfm { + background-color: #e21b24; + color: #fff; + } + + .wp-social-link-linkedin { + background-color: #0577b5; + color: #fff; + } + + .wp-social-link-mastodon { + background-color: #3288d4; + color: #fff; + } + + .wp-social-link-medium { + background-color: #02ab6c; + color: #fff; + } + + .wp-social-link-meetup { + background-color: #f6405f; + color: #fff; + } + + .wp-social-link-pinterest { + background-color: #e60122; + color: #fff; + } + + .wp-social-link-pocket { + background-color: #ef4155; + color: #fff; + } + + .wp-social-link-reddit { + background-color: #fe4500; + color: #fff; + } + + .wp-social-link-skype { + background-color: #0478d7; + color: #fff; + } + + .wp-social-link-snapchat { + background-color: #fefc00; + color: #fff; + stroke: #000; + } + + .wp-social-link-soundcloud { + background-color: #ff5600; + color: #fff; + } + + .wp-social-link-spotify { + background-color: #1bd760; + color: #fff; + } + + .wp-social-link-tumblr { + background-color: #011835; + color: #fff; + } + + .wp-social-link-twitch { + background-color: #6440a4; + color: #fff; + } + + .wp-social-link-twitter { + background-color: #21a1f3; + color: #fff; + } + + .wp-social-link-vimeo { + background-color: #1eb7ea; + color: #fff; + } + + .wp-social-link-vk { + background-color: #4680c2; + color: #fff; + } + + .wp-social-link-wordpress { + background-color: #3499cd; + color: #fff; + } + + .wp-social-link-yelp { + background-color: #d32422; + color: #fff; + } + + .wp-social-link-youtube { + background-color: #ff0100; + color: #fff; + } +} + +// Treatment for logos only style. +.wp-block-social-links.is-style-logos-only { + .wp-social-link { + background: none; + + // Make these bigger. + padding: 4px; + svg { + width: 28px; + height: 28px; + } + } + + .wp-social-link-amazon { + color: #f90; + } + + .wp-social-link-bandcamp { + color: #1ea0c3; + } + + .wp-social-link-behance { + color: #0757fe; + } + + .wp-social-link-codepen { + color: #1e1f26; + } + + .wp-social-link-deviantart { + color: #02e49b; + } + + .wp-social-link-dribbble { + color: #e94c89; + } + + .wp-social-link-dropbox { + color: #4280ff; + } + + .wp-social-link-etsy { + color: #f45800; + } + + .wp-social-link-facebook { + color: #1977f2; + } + + .wp-social-link-fivehundredpx { + color: #000; + } + + .wp-social-link-flickr { + color: #0461dd; + } + + .wp-social-link-foursquare { + color: #e65678; + } + + .wp-social-link-github { + color: #24292d; + } + + .wp-social-link-goodreads { + color: #382110; + } + + .wp-social-link-google { + color: #ea4434; + } + + .wp-social-link-instagram { + color: #f00075; + } + + .wp-social-link-lastfm { + color: #e21b24; + } + + .wp-social-link-linkedin { + color: #0577b5; + } + + .wp-social-link-mastodon { + color: #3288d4; + } + + .wp-social-link-medium { + color: #02ab6c; + } + + .wp-social-link-meetup { + color: #f6405f; + } + + .wp-social-link-pinterest { + color: #e60122; + } + + .wp-social-link-pocket { + color: #ef4155; + } + + .wp-social-link-reddit { + color: #fe4500; + } + + .wp-social-link-skype { + color: #0478d7; + } + + .wp-social-link-snapchat { + color: #fff; + stroke: #000; + } + + .wp-social-link-soundcloud { + color: #ff5600; + } + + .wp-social-link-spotify { + color: #1bd760; + } + + .wp-social-link-tumblr { + color: #011835; + } + + .wp-social-link-twitch { + color: #6440a4; + } + + .wp-social-link-twitter { + color: #21a1f3; + } + + .wp-social-link-vimeo { + color: #1eb7ea; + } + + .wp-social-link-vk { + color: #4680c2; + } + + .wp-social-link-wordpress { + color: #3499cd; + } + + .wp-social-link-yelp { + background-color: #d32422; + color: #fff; + } + + .wp-social-link-youtube { + color: #ff0100; + } +} + +// Treatment for pill shape style. +.wp-block-social-links.is-style-pill-shape { + .wp-social-link { + width: auto; + } + + .wp-social-link a { + padding-left: 16px; + padding-right: 16px; + } +} diff --git a/gb-src/packages/block-library/src/spacer/block.json b/gb-src/packages/block-library/src/spacer/block.json new file mode 100644 index 0000000000000..c8240f036ca77 --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/block.json @@ -0,0 +1,10 @@ +{ + "name": "core/spacer", + "category": "layout", + "attributes": { + "height": { + "type": "number", + "default": 100 + } + } +} diff --git a/gb-src/packages/block-library/src/spacer/edit.js b/gb-src/packages/block-library/src/spacer/edit.js new file mode 100644 index 0000000000000..a9b0e2626e481 --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/edit.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import { BaseControl, PanelBody, ResizableBox } from '@wordpress/components'; +import { compose, withInstanceId } from '@wordpress/compose'; +import { withDispatch } from '@wordpress/data'; + +const SpacerEdit = ( { attributes, isSelected, setAttributes, instanceId, onResizeStart, onResizeStop } ) => { + const { height } = attributes; + const id = `block-spacer-height-input-${ instanceId }`; + const [ inputHeightValue, setInputHeightValue ] = useState( height ); + + return ( + <> + <ResizableBox + className={ classnames( + 'block-library-spacer__resize-container', + { 'is-selected': isSelected }, + ) } + size={ { + height, + } } + minHeight="20" + enable={ { + top: false, + right: false, + bottom: true, + left: false, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, + } } + onResizeStart={ onResizeStart } + onResizeStop={ ( event, direction, elt, delta ) => { + onResizeStop(); + const spacerHeight = parseInt( height + delta.height, 10 ); + setAttributes( { + height: spacerHeight, + } ); + setInputHeightValue( spacerHeight ); + } } + /> + <InspectorControls> + <PanelBody title={ __( 'Spacer Settings' ) }> + <BaseControl label={ __( 'Height in pixels' ) } id={ id }> + <input + type="number" + id={ id } + onChange={ ( event ) => { + let spacerHeight = parseInt( event.target.value, 10 ); + setInputHeightValue( spacerHeight ); + if ( isNaN( spacerHeight ) ) { + // Set spacer height to default size and input box to empty string + setInputHeightValue( '' ); + spacerHeight = 100; + } else if ( spacerHeight < 20 ) { + // Set spacer height to minimum size + spacerHeight = 20; + } + setAttributes( { + height: spacerHeight, + } ); + } } + value={ inputHeightValue } + min="20" + step="10" + /> + </BaseControl> + </PanelBody> + </InspectorControls> + </> + ); +}; + +export default compose( [ + withDispatch( ( dispatch ) => { + const { toggleSelection } = dispatch( 'core/block-editor' ); + + return { + onResizeStart: () => toggleSelection( false ), + onResizeStop: () => toggleSelection( true ), + }; + } ), + withInstanceId, +] )( SpacerEdit ); diff --git a/gb-src/packages/block-library/src/spacer/editor.scss b/gb-src/packages/block-library/src/spacer/editor.scss new file mode 100644 index 0000000000000..b20657931c97e --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/editor.scss @@ -0,0 +1,8 @@ +.block-library-spacer__resize-container.is-selected { + background: $light-gray-200; +} + +.block-library-spacer__resize-container { + clear: both; + margin-bottom: $default-block-margin; +} diff --git a/gb-src/packages/block-library/src/spacer/icon.js b/gb-src/packages/block-library/src/spacer/icon.js new file mode 100644 index 0000000000000..d16b440f7d556 --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><G><Path d="M13 4v2h3.59L6 16.59V13H4v7h7v-2H7.41L18 7.41V11h2V4h-7" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/spacer/index.js b/gb-src/packages/block-library/src/spacer/index.js new file mode 100644 index 0000000000000..09eb2e40c5c90 --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Spacer' ), + description: __( 'Add white space between blocks and customize its height.' ), + icon, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/spacer/save.js b/gb-src/packages/block-library/src/spacer/save.js new file mode 100644 index 0000000000000..4e8067627c306 --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/save.js @@ -0,0 +1,3 @@ +export default function save( { attributes } ) { + return <div style={ { height: attributes.height } } aria-hidden />; +} diff --git a/gb-src/packages/block-library/src/spacer/style.scss b/gb-src/packages/block-library/src/spacer/style.scss new file mode 100644 index 0000000000000..e6b5ee9d5085b --- /dev/null +++ b/gb-src/packages/block-library/src/spacer/style.scss @@ -0,0 +1,3 @@ +.wp-block-spacer { + clear: both; +} diff --git a/gb-src/packages/block-library/src/style.scss b/gb-src/packages/block-library/src/style.scss new file mode 100644 index 0000000000000..e06d73cfe2e40 --- /dev/null +++ b/gb-src/packages/block-library/src/style.scss @@ -0,0 +1,172 @@ +@import "./audio/style.scss"; +@import "./button/style.scss"; +@import "./calendar/style.scss"; +@import "./categories/style.scss"; +@import "./columns/style.scss"; +@import "./cover/style.scss"; +@import "./embed/style.scss"; +@import "./file/style.scss"; +@import "./gallery/style.scss"; +@import "./image/style.scss"; +@import "./latest-comments/style.scss"; +@import "./latest-posts/style.scss"; +@import "./media-text/style.scss"; +@import "./paragraph/style.scss"; +@import "./pullquote/style.scss"; +@import "./quote/style.scss"; +@import "./rss/style.scss"; +@import "./search/style.scss"; +@import "./separator/style.scss"; +@import "./social-links/style.scss"; +@import "./spacer/style.scss"; +@import "./subhead/style.scss"; +@import "./table/style.scss"; +@import "./text-columns/style.scss"; +@import "./verse/style.scss"; +@import "./video/style.scss"; + +// The following selectors have increased specificity (using the :root prefix) +// to assure colors take effect over another base class color, mainly to let +// the colors override the added specificity by link states such as :hover. + +:root { + // Background colors. + + .has-pale-pink-background-color { + background-color: #f78da7; + } + + .has-vivid-red-background-color { + background-color: #cf2e2e; + } + + .has-luminous-vivid-orange-background-color { + background-color: #ff6900; + } + + .has-luminous-vivid-amber-background-color { + background-color: #fcb900; + } + + .has-light-green-cyan-background-color { + background-color: #7bdcb5; + } + + .has-vivid-green-cyan-background-color { + background-color: #00d084; + } + + .has-pale-cyan-blue-background-color { + background-color: #8ed1fc; + } + + .has-vivid-cyan-blue-background-color { + background-color: #0693e3; + } + + .has-vivid-purple-background-color { + background-color: #9b51e0; + } + + .has-very-light-gray-background-color { + background-color: #eee; + } + + .has-cyan-bluish-gray-background-color { + background-color: #abb8c3; + } + + .has-very-dark-gray-background-color { + background-color: #313131; + } + + // Foreground colors. + + .has-pale-pink-color { + color: #f78da7; + } + + .has-vivid-red-color { + color: #cf2e2e; + } + + .has-luminous-vivid-orange-color { + color: #ff6900; + } + + .has-luminous-vivid-amber-color { + color: #fcb900; + } + + .has-light-green-cyan-color { + color: #7bdcb5; + } + + .has-vivid-green-cyan-color { + color: #00d084; + } + + .has-pale-cyan-blue-color { + color: #8ed1fc; + } + + .has-vivid-cyan-blue-color { + color: #0693e3; + } + + .has-vivid-purple-color { + color: #9b51e0; + } + + .has-very-light-gray-color { + color: #eee; + } + + .has-cyan-bluish-gray-color { + color: #abb8c3; + } + + .has-very-dark-gray-color { + color: #313131; + } +} + + +// Font sizes. + +.has-small-font-size { + font-size: 13px; +} + +.has-regular-font-size, // Not used now, kept because of backward compatibility. +.has-normal-font-size { + font-size: 16px; +} + +.has-medium-font-size { + font-size: 20px; +} + +.has-large-font-size { + font-size: 36px; +} + +.has-larger-font-size, // Not used now, kept because of backward compatibility. +.has-huge-font-size { + font-size: 42px; +} + +// Text alignments. +.has-text-align-center { + text-align: center; +} + +.has-text-align-left { + /*rtl:ignore*/ + text-align: left; +} + +.has-text-align-right { + /*rtl:ignore*/ + text-align: right; +} diff --git a/gb-src/packages/block-library/src/subhead/block.json b/gb-src/packages/block-library/src/subhead/block.json new file mode 100644 index 0000000000000..ebb48d386ed3d --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/block.json @@ -0,0 +1,14 @@ +{ + "name": "core/subhead", + "category": "common", + "attributes": { + "align": { + "type": "string" + }, + "content": { + "type": "string", + "source": "html", + "selector": "p" + } + } +} diff --git a/gb-src/packages/block-library/src/subhead/edit.js b/gb-src/packages/block-library/src/subhead/edit.js new file mode 100644 index 0000000000000..8ceda8116ad7c --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/edit.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; +import { __ } from '@wordpress/i18n'; +import { + RichText, + BlockControls, + AlignmentToolbar, +} from '@wordpress/block-editor'; + +export default function SubheadEdit( { attributes, setAttributes, className } ) { + const { align, content, placeholder } = attributes; + + deprecated( 'The Subheading block', { + alternative: 'the Paragraph block', + plugin: 'Gutenberg', + } ); + + return ( + <> + <BlockControls> + <AlignmentToolbar + value={ align } + onChange={ ( nextAlign ) => { + setAttributes( { align: nextAlign } ); + } } + /> + </BlockControls> + <RichText + tagName="p" + value={ content } + onChange={ ( nextContent ) => { + setAttributes( { + content: nextContent, + } ); + } } + style={ { textAlign: align } } + className={ className } + placeholder={ placeholder || __( 'Write subheading…' ) } + /> + </> + ); +} diff --git a/gb-src/packages/block-library/src/subhead/editor.scss b/gb-src/packages/block-library/src/subhead/editor.scss new file mode 100644 index 0000000000000..29fb865285658 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/editor.scss @@ -0,0 +1,5 @@ +.edit-post-visual-editor p.wp-block-subhead { + color: $dark-gray-300; + font-size: 1.1em; + font-style: italic; +} diff --git a/gb-src/packages/block-library/src/subhead/icon.js b/gb-src/packages/block-library/src/subhead/icon.js new file mode 100644 index 0000000000000..cbc71f6a325d0 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M7.1 6l-.5 3h4.5L9.4 19h3l1.8-10h4.5l.5-3H7.1z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/subhead/index.js b/gb-src/packages/block-library/src/subhead/index.js new file mode 100644 index 0000000000000..1e58e8be94282 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/index.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './tranforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Subheading (deprecated)' ), + description: __( 'This block is deprecated. Please use the Paragraph block instead.' ), + icon, + supports: { + // Hide from inserter as this block is deprecated. + inserter: false, + multiple: false, + }, + transforms, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/subhead/save.js b/gb-src/packages/block-library/src/subhead/save.js new file mode 100644 index 0000000000000..10c0f2b256993 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/save.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { align, content } = attributes; + + return ( + <RichText.Content + tagName="p" + style={ { textAlign: align } } + value={ content } + /> + ); +} diff --git a/gb-src/packages/block-library/src/subhead/style.scss b/gb-src/packages/block-library/src/subhead/style.scss new file mode 100644 index 0000000000000..6e6f48e6bf827 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/style.scss @@ -0,0 +1,5 @@ +p.wp-block-subhead { + font-size: 1.1em; + font-style: italic; + opacity: 0.75; +} diff --git a/gb-src/packages/block-library/src/subhead/tranforms.js b/gb-src/packages/block-library/src/subhead/tranforms.js new file mode 100644 index 0000000000000..6e05f8501b5a3 --- /dev/null +++ b/gb-src/packages/block-library/src/subhead/tranforms.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( attributes ) => + createBlock( 'core/paragraph', attributes ), + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/table/block.json b/gb-src/packages/block-library/src/table/block.json new file mode 100644 index 0000000000000..539c4df88ab7c --- /dev/null +++ b/gb-src/packages/block-library/src/table/block.json @@ -0,0 +1,118 @@ +{ + "name": "core/table", + "category": "formatting", + "attributes": { + "hasFixedLayout": { + "type": "boolean", + "default": false + }, + "backgroundColor": { + "type": "string" + }, + "head": { + "type": "array", + "default": [], + "source": "query", + "selector": "thead tr", + "query": { + "cells": { + "type": "array", + "default": [], + "source": "query", + "selector": "td,th", + "query": { + "content": { + "type": "string", + "source": "html" + }, + "tag": { + "type": "string", + "default": "td", + "source": "tag" + }, + "scope": { + "type": "string", + "source": "attribute", + "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" + } + } + } + } + }, + "body": { + "type": "array", + "default": [], + "source": "query", + "selector": "tbody tr", + "query": { + "cells": { + "type": "array", + "default": [], + "source": "query", + "selector": "td,th", + "query": { + "content": { + "type": "string", + "source": "html" + }, + "tag": { + "type": "string", + "default": "td", + "source": "tag" + }, + "scope": { + "type": "string", + "source": "attribute", + "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" + } + } + } + } + }, + "foot": { + "type": "array", + "default": [], + "source": "query", + "selector": "tfoot tr", + "query": { + "cells": { + "type": "array", + "default": [], + "source": "query", + "selector": "td,th", + "query": { + "content": { + "type": "string", + "source": "html" + }, + "tag": { + "type": "string", + "default": "td", + "source": "tag" + }, + "scope": { + "type": "string", + "source": "attribute", + "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" + } + } + } + } + } + } +} diff --git a/gb-src/packages/block-library/src/table/deprecated.js b/gb-src/packages/block-library/src/table/deprecated.js new file mode 100644 index 0000000000000..79fc5726ea23f --- /dev/null +++ b/gb-src/packages/block-library/src/table/deprecated.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText, getColorClassName } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; + +const supports = { + align: true, +}; + +const deprecated = [ + { + attributes: metadata.attributes, + supports, + save( { attributes } ) { + const { + hasFixedLayout, + head, + body, + foot, + backgroundColor, + } = attributes; + const isEmpty = ! head.length && ! body.length && ! foot.length; + + if ( isEmpty ) { + return null; + } + + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + + const classes = classnames( backgroundClass, { + 'has-fixed-layout': hasFixedLayout, + 'has-background': !! backgroundClass, + } ); + + const Section = ( { type, rows } ) => { + if ( ! rows.length ) { + return null; + } + + const Tag = `t${ type }`; + + return ( + <Tag> + { rows.map( ( { cells }, rowIndex ) => ( + <tr key={ rowIndex }> + { cells.map( ( { content, tag, scope }, cellIndex ) => + <RichText.Content + tagName={ tag } + value={ content } + key={ cellIndex } + scope={ tag === 'th' ? scope : undefined } + /> + ) } + </tr> + ) ) } + </Tag> + ); + }; + + return ( + <table className={ classes }> + <Section type="head" rows={ head } /> + <Section type="body" rows={ body } /> + <Section type="foot" rows={ foot } /> + </table> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/table/edit.js b/gb-src/packages/block-library/src/table/edit.js new file mode 100644 index 0000000000000..b86c0e282d7f9 --- /dev/null +++ b/gb-src/packages/block-library/src/table/edit.js @@ -0,0 +1,607 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + InspectorControls, + BlockControls, + RichText, + PanelColorSettings, + createCustomColorsHOC, + BlockIcon, + AlignmentToolbar, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { + PanelBody, + ToggleControl, + TextControl, + Button, + Toolbar, + DropdownMenu, + Placeholder, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { + createTable, + updateSelectedCell, + getCellAttribute, + insertRow, + deleteRow, + insertColumn, + deleteColumn, + toggleSection, + isEmptyTableSection, + isCellSelected, +} from './state'; +import icon from './icon'; + +const BACKGROUND_COLORS = [ + { + color: '#f3f4f5', + name: 'Subtle light gray', + slug: 'subtle-light-gray', + }, + { + color: '#e9fbe5', + name: 'Subtle pale green', + slug: 'subtle-pale-green', + }, + { + color: '#e7f5fe', + name: 'Subtle pale blue', + slug: 'subtle-pale-blue', + }, + { + color: '#fcf0ef', + name: 'Subtle pale pink', + slug: 'subtle-pale-pink', + }, +]; + +const ALIGNMENT_CONTROLS = [ + { + icon: 'editor-alignleft', + title: __( 'Align Column Left' ), + align: 'left', + }, + { + icon: 'editor-aligncenter', + title: __( 'Align Column Center' ), + align: 'center', + }, + { + icon: 'editor-alignright', + title: __( 'Align Column Right' ), + align: 'right', + }, +]; + +const withCustomBackgroundColors = createCustomColorsHOC( BACKGROUND_COLORS ); + +export class TableEdit extends Component { + constructor() { + super( ...arguments ); + + this.onCreateTable = this.onCreateTable.bind( this ); + this.onChangeFixedLayout = this.onChangeFixedLayout.bind( this ); + this.onChange = this.onChange.bind( this ); + this.onChangeInitialColumnCount = this.onChangeInitialColumnCount.bind( this ); + this.onChangeInitialRowCount = this.onChangeInitialRowCount.bind( this ); + this.renderSection = this.renderSection.bind( this ); + this.getTableControls = this.getTableControls.bind( this ); + this.onInsertRow = this.onInsertRow.bind( this ); + this.onInsertRowBefore = this.onInsertRowBefore.bind( this ); + this.onInsertRowAfter = this.onInsertRowAfter.bind( this ); + this.onDeleteRow = this.onDeleteRow.bind( this ); + this.onInsertColumn = this.onInsertColumn.bind( this ); + this.onInsertColumnBefore = this.onInsertColumnBefore.bind( this ); + this.onInsertColumnAfter = this.onInsertColumnAfter.bind( this ); + this.onDeleteColumn = this.onDeleteColumn.bind( this ); + this.onToggleHeaderSection = this.onToggleHeaderSection.bind( this ); + this.onToggleFooterSection = this.onToggleFooterSection.bind( this ); + this.onChangeColumnAlignment = this.onChangeColumnAlignment.bind( this ); + this.getCellAlignment = this.getCellAlignment.bind( this ); + + this.state = { + initialRowCount: 2, + initialColumnCount: 2, + selectedCell: null, + }; + } + + /** + * Updates the initial column count used for table creation. + * + * @param {number} initialColumnCount New initial column count. + */ + onChangeInitialColumnCount( initialColumnCount ) { + this.setState( { initialColumnCount } ); + } + + /** + * Updates the initial row count used for table creation. + * + * @param {number} initialRowCount New initial row count. + */ + onChangeInitialRowCount( initialRowCount ) { + this.setState( { initialRowCount } ); + } + + /** + * Creates a table based on dimensions in local state. + * + * @param {Object} event Form submit event. + */ + onCreateTable( event ) { + event.preventDefault(); + + const { setAttributes } = this.props; + let { initialRowCount, initialColumnCount } = this.state; + + initialRowCount = parseInt( initialRowCount, 10 ) || 2; + initialColumnCount = parseInt( initialColumnCount, 10 ) || 2; + + setAttributes( createTable( { + rowCount: initialRowCount, + columnCount: initialColumnCount, + } ) ); + } + + /** + * Toggles whether the table has a fixed layout or not. + */ + onChangeFixedLayout() { + const { attributes, setAttributes } = this.props; + const { hasFixedLayout } = attributes; + + setAttributes( { hasFixedLayout: ! hasFixedLayout } ); + } + + /** + * Changes the content of the currently selected cell. + * + * @param {Array} content A RichText content value. + */ + onChange( content ) { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes, setAttributes } = this.props; + + setAttributes( updateSelectedCell( + attributes, + selectedCell, + ( cellAttributes ) => ( { ...cellAttributes, content } ), + ) ); + } + + /** + * Align text within the a column. + * + * @param {string} align The new alignment to apply to the column. + */ + onChangeColumnAlignment( align ) { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + // Convert the cell selection to a column selection so that alignment + // is applied to the entire column. + const columnSelection = { + type: 'column', + columnIndex: selectedCell.columnIndex, + }; + + const { attributes, setAttributes } = this.props; + const newAttributes = updateSelectedCell( + attributes, + columnSelection, + ( cellAttributes ) => ( { ...cellAttributes, align } ), + ); + setAttributes( newAttributes ); + } + + /** + * Get the alignment of the currently selected cell. + * + * @return {string} The new alignment to apply to the column. + */ + getCellAlignment() { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes } = this.props; + + return getCellAttribute( attributes, selectedCell, 'align' ); + } + + /** + * Add or remove a `head` table section. + */ + onToggleHeaderSection() { + const { attributes, setAttributes } = this.props; + setAttributes( toggleSection( attributes, 'head' ) ); + } + + /** + * Add or remove a `foot` table section. + */ + onToggleFooterSection() { + const { attributes, setAttributes } = this.props; + setAttributes( toggleSection( attributes, 'foot' ) ); + } + + /** + * Inserts a row at the currently selected row index, plus `delta`. + * + * @param {number} delta Offset for selected row index at which to insert. + */ + onInsertRow( delta ) { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes, setAttributes } = this.props; + const { sectionName, rowIndex } = selectedCell; + + this.setState( { selectedCell: null } ); + setAttributes( insertRow( attributes, { + sectionName, + rowIndex: rowIndex + delta, + } ) ); + } + + /** + * Inserts a row before the currently selected row. + */ + onInsertRowBefore() { + this.onInsertRow( 0 ); + } + + /** + * Inserts a row after the currently selected row. + */ + onInsertRowAfter() { + this.onInsertRow( 1 ); + } + + /** + * Deletes the currently selected row. + */ + onDeleteRow() { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes, setAttributes } = this.props; + const { sectionName, rowIndex } = selectedCell; + + this.setState( { selectedCell: null } ); + setAttributes( deleteRow( attributes, { sectionName, rowIndex } ) ); + } + + /** + * Inserts a column at the currently selected column index, plus `delta`. + * + * @param {number} delta Offset for selected column index at which to insert. + */ + onInsertColumn( delta = 0 ) { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes, setAttributes } = this.props; + const { columnIndex } = selectedCell; + + this.setState( { selectedCell: null } ); + setAttributes( insertColumn( attributes, { + columnIndex: columnIndex + delta, + } ) ); + } + + /** + * Inserts a column before the currently selected column. + */ + onInsertColumnBefore() { + this.onInsertColumn( 0 ); + } + + /** + * Inserts a column after the currently selected column. + */ + onInsertColumnAfter() { + this.onInsertColumn( 1 ); + } + + /** + * Deletes the currently selected column. + */ + onDeleteColumn() { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes, setAttributes } = this.props; + const { sectionName, columnIndex } = selectedCell; + + this.setState( { selectedCell: null } ); + setAttributes( deleteColumn( attributes, { sectionName, columnIndex } ) ); + } + + /** + * Creates an onFocus handler for a specified cell. + * + * @param {Object} cellLocation Object with `section`, `rowIndex`, and + * `columnIndex` properties. + * + * @return {Function} Function to call on focus. + */ + createOnFocus( cellLocation ) { + return () => { + this.setState( { + selectedCell: { + ...cellLocation, + type: 'cell', + }, + } ); + }; + } + + /** + * Gets the table controls to display in the block toolbar. + * + * @return {Array} Table controls. + */ + getTableControls() { + const { selectedCell } = this.state; + + return [ + { + icon: 'table-row-before', + title: __( 'Add Row Before' ), + isDisabled: ! selectedCell, + onClick: this.onInsertRowBefore, + }, + { + icon: 'table-row-after', + title: __( 'Add Row After' ), + isDisabled: ! selectedCell, + onClick: this.onInsertRowAfter, + }, + { + icon: 'table-row-delete', + title: __( 'Delete Row' ), + isDisabled: ! selectedCell, + onClick: this.onDeleteRow, + }, + { + icon: 'table-col-before', + title: __( 'Add Column Before' ), + isDisabled: ! selectedCell, + onClick: this.onInsertColumnBefore, + }, + { + icon: 'table-col-after', + title: __( 'Add Column After' ), + isDisabled: ! selectedCell, + onClick: this.onInsertColumnAfter, + }, + { + icon: 'table-col-delete', + title: __( 'Delete Column' ), + isDisabled: ! selectedCell, + onClick: this.onDeleteColumn, + }, + ]; + } + + /** + * Renders a table section. + * + * @param {Object} options + * @param {string} options.type Section type: head, body, or foot. + * @param {Array} options.rows The rows to render. + * + * @return {Object} React element for the section. + */ + renderSection( { name, rows } ) { + if ( isEmptyTableSection( rows ) ) { + return null; + } + + const Tag = `t${ name }`; + const { selectedCell } = this.state; + + return ( + <Tag> + { rows.map( ( { cells }, rowIndex ) => ( + <tr key={ rowIndex }> + { cells.map( ( { content, tag: CellTag, scope, align }, columnIndex ) => { + const cellLocation = { + sectionName: name, + rowIndex, + columnIndex, + }; + const isSelected = isCellSelected( cellLocation, selectedCell ); + + const cellClasses = classnames( { + 'is-selected': isSelected, + [ `has-text-align-${ align }` ]: align, + } ); + const richTextClassName = 'wp-block-table__cell-content'; + + return ( + <CellTag + key={ columnIndex } + className={ cellClasses } + scope={ CellTag === 'th' ? scope : undefined } + onClick={ ( event ) => { + // When a cell is selected, forward focus to the child RichText. This solves an issue where the + // user may click inside a cell, but outside of the RichText, resulting in nothing happening. + const richTextElement = event && event.target && event.target.querySelector( `.${ richTextClassName }` ); + if ( richTextElement ) { + richTextElement.focus(); + } + } } + > + <RichText + className={ richTextClassName } + value={ content } + onChange={ this.onChange } + unstableOnFocus={ this.createOnFocus( cellLocation ) } + /> + </CellTag> + ); + } ) } + </tr> + ) ) } + </Tag> + ); + } + + componentDidUpdate() { + const { isSelected } = this.props; + const { selectedCell } = this.state; + + if ( ! isSelected && selectedCell ) { + this.setState( { selectedCell: null } ); + } + } + + render() { + const { + attributes, + className, + backgroundColor, + setBackgroundColor, + } = this.props; + const { initialRowCount, initialColumnCount } = this.state; + const { hasFixedLayout, head, body, foot } = attributes; + const isEmpty = isEmptyTableSection( head ) && isEmptyTableSection( body ) && isEmptyTableSection( foot ); + const Section = this.renderSection; + + if ( isEmpty ) { + return ( + <Placeholder + label={ __( 'Table' ) } + icon={ <BlockIcon icon={ icon } showColors /> } + instructions={ __( 'Insert a table for sharing data.' ) } + isColumnLayout + > + <form className="wp-block-table__placeholder-form" onSubmit={ this.onCreateTable }> + <TextControl + type="number" + label={ __( 'Column Count' ) } + value={ initialColumnCount } + onChange={ this.onChangeInitialColumnCount } + min="1" + className="wp-block-table__placeholder-input" + /> + <TextControl + type="number" + label={ __( 'Row Count' ) } + value={ initialRowCount } + onChange={ this.onChangeInitialRowCount } + min="1" + className="wp-block-table__placeholder-input" + /> + <Button className="wp-block-table__placeholder-button" isDefault type="submit">{ __( 'Create Table' ) }</Button> + </form> + </Placeholder> + ); + } + + const tableClasses = classnames( backgroundColor.class, { + 'has-fixed-layout': hasFixedLayout, + 'has-background': !! backgroundColor.color, + } ); + + return ( + <> + <BlockControls> + <Toolbar> + <DropdownMenu + hasArrowIndicator + icon="editor-table" + label={ __( 'Edit table' ) } + controls={ this.getTableControls() } + /> + </Toolbar> + <AlignmentToolbar + label={ __( 'Change column alignment' ) } + alignmentControls={ ALIGNMENT_CONTROLS } + value={ this.getCellAlignment() } + onChange={ ( nextAlign ) => this.onChangeColumnAlignment( nextAlign ) } + onHover={ this.onHoverAlignment } + /> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Table Settings' ) } className="blocks-table-settings"> + <ToggleControl + label={ __( 'Fixed width table cells' ) } + checked={ !! hasFixedLayout } + onChange={ this.onChangeFixedLayout } + /> + <ToggleControl + label={ __( 'Header section' ) } + checked={ !! ( head && head.length ) } + onChange={ this.onToggleHeaderSection } + /> + <ToggleControl + label={ __( 'Footer section' ) } + checked={ !! ( foot && foot.length ) } + onChange={ this.onToggleFooterSection } + /> + </PanelBody> + <PanelColorSettings + title={ __( 'Color Settings' ) } + initialOpen={ false } + colorSettings={ [ + { + value: backgroundColor.color, + onChange: setBackgroundColor, + label: __( 'Background Color' ), + disableCustomColors: true, + colors: BACKGROUND_COLORS, + }, + ] } + /> + </InspectorControls> + <figure className={ className }> + <table className={ tableClasses }> + <Section name="head" rows={ head } /> + <Section name="body" rows={ body } /> + <Section name="foot" rows={ foot } /> + </table> + </figure> + </> + ); + } +} + +export default withCustomBackgroundColors( 'backgroundColor' )( TableEdit ); diff --git a/gb-src/packages/block-library/src/table/editor.scss b/gb-src/packages/block-library/src/table/editor.scss new file mode 100644 index 0000000000000..f5dbb5f2643c0 --- /dev/null +++ b/gb-src/packages/block-library/src/table/editor.scss @@ -0,0 +1,65 @@ +.block-editor-block-list__block[data-type="core/table"] { + &[data-align="left"], + &[data-align="right"], + &[data-align="center"] { + // Stop table block from collapsing when tables are floated. + height: auto; + + table { + // Ensure the table element is not full-width when aligned. + width: auto; + } + + td, + th { + word-break: break-word; + } + } + + &[data-align="center"] { + text-align: initial; + + table { + margin: 0 auto; + } + } +} + + +.wp-block-table { + // Remove default <figure> style. + margin: 0; + + table { + border-collapse: collapse; + } + + td, + th { + padding: 0; + border: $border-width solid; + } + + td.is-selected, + th.is-selected { + border-color: $blue-medium-500; + box-shadow: inset 0 0 0 1px $blue-medium-500; + border-style: double; + } + + &__cell-content { + padding: 0.5em; + } + // Extra specificity to override default Placeholder styles. + &__placeholder-form.wp-block-table__placeholder-form { + text-align: left; + align-items: center; + } + &__placeholder-input { + width: 100px; + } + &__placeholder-button { + min-width: 100px; + justify-content: center; + } +} diff --git a/gb-src/packages/block-library/src/table/icon.js b/gb-src/packages/block-library/src/table/icon.js new file mode 100644 index 0000000000000..0292142033cf2 --- /dev/null +++ b/gb-src/packages/block-library/src/table/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M20 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h15c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 2v3H5V5h15zm-5 14h-5v-9h5v9zM5 10h3v9H5v-9zm12 9v-9h3v9h-3z" /></G></SVG> +); diff --git a/gb-src/packages/block-library/src/table/index.js b/gb-src/packages/block-library/src/table/index.js new file mode 100644 index 0000000000000..cb4e69f1988fe --- /dev/null +++ b/gb-src/packages/block-library/src/table/index.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Table' ), + description: __( 'Insert a table — perfect for sharing charts and data.' ), + icon, + example: { + attributes: { + head: [ { + cells: [ + { + content: __( 'Version' ), + tag: 'th', + }, + { + content: __( 'Jazz Musician' ), + tag: 'th', + }, + { + content: __( 'Release Date' ), + tag: 'th', + }, + ], + } ], + body: [ + { + cells: [ + { + content: '5.2', + tag: 'td', + }, + { + content: 'Jaco Pastorius', + tag: 'td', + }, + { + content: __( 'May 7, 2019' ), + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '5.1', + tag: 'td', + }, + { + content: 'Betty Carter', + tag: 'td', + }, + { + content: __( 'February 21, 2019' ), + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '5.0', + tag: 'td', + }, + { + content: 'Bebo Valdés', + tag: 'td', + }, + { + content: __( 'December 6, 2018' ), + tag: 'td', + }, + ], + }, + ], + }, + }, + styles: [ + { name: 'regular', label: _x( 'Default', 'block style' ), isDefault: true }, + { name: 'stripes', label: __( 'Stripes' ) }, + ], + supports: { + align: true, + }, + transforms, + edit, + save, + deprecated, +}; diff --git a/gb-src/packages/block-library/src/table/save.js b/gb-src/packages/block-library/src/table/save.js new file mode 100644 index 0000000000000..0911f20fe85bf --- /dev/null +++ b/gb-src/packages/block-library/src/table/save.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText, getColorClassName } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { + hasFixedLayout, + head, + body, + foot, + backgroundColor, + } = attributes; + const isEmpty = ! head.length && ! body.length && ! foot.length; + + if ( isEmpty ) { + return null; + } + + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + + const classes = classnames( backgroundClass, { + 'has-fixed-layout': hasFixedLayout, + 'has-background': !! backgroundClass, + } ); + + const Section = ( { type, rows } ) => { + if ( ! rows.length ) { + return null; + } + + const Tag = `t${ type }`; + + return ( + <Tag> + { rows.map( ( { cells }, rowIndex ) => ( + <tr key={ rowIndex }> + { cells.map( ( { content, tag, scope, align }, cellIndex ) => { + const cellClasses = classnames( { + [ `has-text-align-${ align }` ]: align, + } ); + + return ( + <RichText.Content + className={ cellClasses ? cellClasses : undefined } + data-align={ align } + tagName={ tag } + value={ content } + key={ cellIndex } + scope={ tag === 'th' ? scope : undefined } + /> + ); + } ) } + </tr> + ) ) } + </Tag> + ); + }; + + return ( + <figure> + <table className={ classes }> + <Section type="head" rows={ head } /> + <Section type="body" rows={ body } /> + <Section type="foot" rows={ foot } /> + </table> + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/table/state.js b/gb-src/packages/block-library/src/table/state.js new file mode 100644 index 0000000000000..f8ee18d7f2390 --- /dev/null +++ b/gb-src/packages/block-library/src/table/state.js @@ -0,0 +1,311 @@ +/** + * External dependencies + */ +import { times, get, mapValues, every, pick } from 'lodash'; + +const INHERITED_COLUMN_ATTRIBUTES = [ 'align' ]; + +/** + * Creates a table state. + * + * @param {Object} options + * @param {number} options.rowCount Row count for the table to create. + * @param {number} options.columnCount Column count for the table to create. + * + * @return {Object} New table state. + */ +export function createTable( { + rowCount, + columnCount, +} ) { + return { + body: times( rowCount, () => ( { + cells: times( columnCount, () => ( { + content: '', + tag: 'td', + } ) ), + } ) ), + }; +} + +/** + * Returns the first row in the table. + * + * @param {Object} state Current table state. + * + * @return {Object} The first table row. + */ +export function getFirstRow( state ) { + if ( ! isEmptyTableSection( state.head ) ) { + return state.head[ 0 ]; + } + if ( ! isEmptyTableSection( state.body ) ) { + return state.body[ 0 ]; + } + if ( ! isEmptyTableSection( state.foot ) ) { + return state.foot[ 0 ]; + } +} + +/** + * Gets an attribute for a cell. + * + * @param {Object} state Current table state. + * @param {Object} cellLocation The location of the cell + * @param {string} attributeName The name of the attribute to get the value of. + * + * @return {*} The attribute value. + */ +export function getCellAttribute( state, cellLocation, attributeName ) { + const { + sectionName, + rowIndex, + columnIndex, + } = cellLocation; + return get( state, [ sectionName, rowIndex, 'cells', columnIndex, attributeName ] ); +} + +/** + * Returns updated cell attributes after applying the `updateCell` function to the selection. + * + * @param {Object} state The block attributes. + * @param {Object} selection The selection of cells to update. + * @param {Function} updateCell A function to update the selected cell attributes. + * + * @return {Object} New table state including the updated cells. + */ +export function updateSelectedCell( state, selection, updateCell ) { + if ( ! selection ) { + return state; + } + + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + const { + sectionName: selectionSectionName, + rowIndex: selectionRowIndex, + } = selection; + + return mapValues( tableSections, ( section, sectionName ) => { + if ( selectionSectionName && selectionSectionName !== sectionName ) { + return section; + } + + return section.map( ( row, rowIndex ) => { + if ( selectionRowIndex && selectionRowIndex !== rowIndex ) { + return row; + } + + return { + cells: row.cells.map( ( cellAttributes, columnIndex ) => { + const cellLocation = { + sectionName, + columnIndex, + rowIndex, + }; + + if ( ! isCellSelected( cellLocation, selection ) ) { + return cellAttributes; + } + + return updateCell( cellAttributes ); + } ), + }; + } ); + } ); +} + +/** + * Returns whether the cell at `cellLocation` is included in the selection `selection`. + * + * @param {Object} cellLocation An object containing cell location properties. + * @param {Object} selection An object containing selection properties. + * + * @return {boolean} True if the cell is selected, false otherwise. + */ +export function isCellSelected( cellLocation, selection ) { + if ( ! cellLocation || ! selection ) { + return false; + } + + switch ( selection.type ) { + case 'column': + return selection.type === 'column' && cellLocation.columnIndex === selection.columnIndex; + case 'cell': + return selection.type === 'cell' && + cellLocation.sectionName === selection.sectionName && + cellLocation.columnIndex === selection.columnIndex && + cellLocation.rowIndex === selection.rowIndex; + } +} + +/** + * Inserts a row in the table state. + * + * @param {Object} state Current table state. + * @param {Object} options + * @param {string} options.sectionName Section in which to insert the row. + * @param {number} options.rowIndex Row index at which to insert the row. + * + * @return {Object} New table state. + */ +export function insertRow( state, { + sectionName, + rowIndex, + columnCount, +} ) { + const firstRow = getFirstRow( state ); + const cellCount = columnCount === undefined ? get( firstRow, [ 'cells', 'length' ] ) : columnCount; + + // Bail early if the function cannot determine how many cells to add. + if ( ! cellCount ) { + return state; + } + + return { + [ sectionName ]: [ + ...state[ sectionName ].slice( 0, rowIndex ), + { + cells: times( cellCount, ( index ) => { + const firstCellInColumn = get( firstRow, [ 'cells', index ], {} ); + const inheritedAttributes = pick( firstCellInColumn, INHERITED_COLUMN_ATTRIBUTES ); + + return { + ...inheritedAttributes, + content: '', + tag: sectionName === 'head' ? 'th' : 'td', + }; + } ), + }, + ...state[ sectionName ].slice( rowIndex ), + ], + }; +} + +/** + * Deletes a row from the table state. + * + * @param {Object} state Current table state. + * @param {Object} options + * @param {string} options.sectionName Section in which to delete the row. + * @param {number} options.rowIndex Row index to delete. + * + * @return {Object} New table state. + */ +export function deleteRow( state, { + sectionName, + rowIndex, +} ) { + return { + [ sectionName ]: state[ sectionName ].filter( ( row, index ) => index !== rowIndex ), + }; +} + +/** + * Inserts a column in the table state. + * + * @param {Object} state Current table state. + * @param {Object} options + * @param {number} options.columnIndex Column index at which to insert the column. + * + * @return {Object} New table state. + */ +export function insertColumn( state, { + columnIndex, +} ) { + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + + return mapValues( tableSections, ( section, sectionName ) => { + // Bail early if the table section is empty. + if ( isEmptyTableSection( section ) ) { + return section; + } + + return section.map( ( row ) => { + // Bail early if the row is empty or it's an attempt to insert past + // the last possible index of the array. + if ( isEmptyRow( row ) || row.cells.length < columnIndex ) { + return row; + } + + return { + cells: [ + ...row.cells.slice( 0, columnIndex ), + { + content: '', + tag: sectionName === 'head' ? 'th' : 'td', + }, + ...row.cells.slice( columnIndex ), + ], + }; + } ); + } ); +} + +/** + * Deletes a column from the table state. + * + * @param {Object} state Current table state. + * @param {Object} options + * @param {number} options.columnIndex Column index to delete. + * + * @return {Object} New table state. + */ +export function deleteColumn( state, { + columnIndex, +} ) { + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + + return mapValues( tableSections, ( section ) => { + // Bail early if the table section is empty. + if ( isEmptyTableSection( section ) ) { + return section; + } + + return section.map( ( row ) => ( { + cells: row.cells.length >= columnIndex ? row.cells.filter( ( cell, index ) => index !== columnIndex ) : row.cells, + } ) ).filter( ( row ) => row.cells.length ); + } ); +} + +/** + * Toggles the existance of a section. + * + * @param {Object} state Current table state. + * @param {string} sectionName Name of the section to toggle. + * + * @return {Object} New table state. + */ +export function toggleSection( state, sectionName ) { + // Section exists, replace it with an empty row to remove it. + if ( ! isEmptyTableSection( state[ sectionName ] ) ) { + return { [ sectionName ]: [] }; + } + + // Get the length of the first row of the body to use when creating the header. + const columnCount = get( state, [ 'body', 0, 'cells', 'length' ], 1 ); + + // Section doesn't exist, insert an empty row to create the section. + return insertRow( state, { sectionName, rowIndex: 0, columnCount } ); +} + +/** + * Determines whether a table section is empty. + * + * @param {Object} section Table section state. + * + * @return {boolean} True if the table section is empty, false otherwise. + */ +export function isEmptyTableSection( section ) { + return ! section || ! section.length || every( section, isEmptyRow ); +} + +/** + * Determines whether a table row is empty. + * + * @param {Object} row Table row state. + * + * @return {boolean} True if the table section is empty, false otherwise. + */ +export function isEmptyRow( row ) { + return ! ( row.cells && row.cells.length ); +} diff --git a/gb-src/packages/block-library/src/table/style.scss b/gb-src/packages/block-library/src/table/style.scss new file mode 100644 index 0000000000000..ba3c949bb7cb3 --- /dev/null +++ b/gb-src/packages/block-library/src/table/style.scss @@ -0,0 +1,98 @@ +.wp-block-table { + $subtle-light-gray: #f3f4f5; + $subtle-pale-green: #e9fbe5; + $subtle-pale-blue: #e7f5fe; + $subtle-pale-pink: #fcf0ef; + + overflow-x: auto; + + table { + width: 100%; + } + + // Fixed layout toggle + .has-fixed-layout { + table-layout: fixed; + width: 100%; + + td, + th { + word-break: break-word; + } + } + + &.alignleft, + &.aligncenter, + &.alignright { + // Override default display property for align styles. + // The table element needs to be kept as display table + // for table features to work reliably. + display: table; + // Table cannot be 100% width if it is aligned, so set + // width as auto. + width: auto; + + td, + th { + word-break: break-word; + } + } + + .has-subtle-light-gray-background-color { + background-color: $subtle-light-gray; + } + + .has-subtle-pale-green-background-color { + background-color: $subtle-pale-green; + } + + .has-subtle-pale-blue-background-color { + background-color: $subtle-pale-blue; + } + + .has-subtle-pale-pink-background-color { + background-color: $subtle-pale-pink; + } + + // "Stripes" style variation. + &.is-style-stripes { + border-spacing: 0; + border-collapse: inherit; + background-color: transparent; + + tbody tr:nth-child(odd) { + background-color: $light-gray-200; + } + + &.has-subtle-light-gray-background-color { + tbody tr:nth-child(odd) { + background-color: $subtle-light-gray; + } + } + + &.has-subtle-pale-green-background-color { + tbody tr:nth-child(odd) { + background-color: $subtle-pale-green; + } + } + + &.has-subtle-pale-blue-background-color { + tbody tr:nth-child(odd) { + background-color: $subtle-pale-blue; + } + } + + &.has-subtle-pale-pink-background-color { + tbody tr:nth-child(odd) { + background-color: $subtle-pale-pink; + } + } + + th, + td { + border-color: transparent; + } + + border-bottom: 1px solid $light-gray-200; + } +} diff --git a/gb-src/packages/block-library/src/table/test/state.js b/gb-src/packages/block-library/src/table/test/state.js new file mode 100644 index 0000000000000..b946d45fcb50b --- /dev/null +++ b/gb-src/packages/block-library/src/table/test/state.js @@ -0,0 +1,1214 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + createTable, + getFirstRow, + getCellAttribute, + insertRow, + deleteRow, + insertColumn, + deleteColumn, + toggleSection, + isEmptyTableSection, + isEmptyRow, + isCellSelected, + updateSelectedCell, +} from '../state'; + +const table = deepFreeze( { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], +} ); + +const tableWithHead = deepFreeze( { + head: [ + { + cells: [ + { + content: 'test', + tag: 'th', + }, + ], + }, + ], +} ); + +const tableWithFoot = deepFreeze( { + foot: [ + { + cells: [ + { + content: 'test', + tag: 'td', + }, + ], + }, + ], +} ); + +const tableWithContent = deepFreeze( { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: 'test', + tag: 'td', + }, + ], + }, + ], +} ); + +const tableWithAttribute = deepFreeze( { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + testAttr: 'testVal', + content: '', + tag: 'td', + }, + ], + }, + ], +} ); + +describe( 'createTable', () => { + it( 'should create a table', () => { + const state = createTable( { rowCount: 2, columnCount: 2 } ); + + expect( state ).toEqual( table ); + } ); +} ); + +describe( 'getFirstRow', () => { + it( 'returns the first row in the head when the body is the first table section', () => { + expect( getFirstRow( tableWithHead ) ).toBe( tableWithHead.head[ 0 ] ); + } ); + + it( 'returns the first row in the body when the body is the first table section', () => { + expect( getFirstRow( table ) ).toBe( table.body[ 0 ] ); + } ); + + it( 'returns the first row in the foot when the body is the first table section', () => { + expect( getFirstRow( tableWithFoot ) ).toBe( tableWithFoot.foot[ 0 ] ); + } ); + + it( 'returns `undefined` for an empty table', () => { + expect( getFirstRow( {} ) ).toBeUndefined(); + } ); +} ); + +describe( 'getCellAttribute', () => { + it( 'should get the cell attribute', () => { + const cellLocation = { + sectionName: 'body', + rowIndex: 1, + columnIndex: 1, + }; + const state = getCellAttribute( tableWithAttribute, cellLocation, 'testAttr' ); + + expect( state ).toBe( 'testVal' ); + } ); +} ); + +describe( 'insertRow', () => { + it( 'should insert row', () => { + const state = insertRow( tableWithContent, { + sectionName: 'body', + rowIndex: 2, + } ); + + const expected = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: 'test', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'allows the number of columns to be specified', () => { + const state = insertRow( tableWithContent, { + sectionName: 'body', + rowIndex: 2, + columnCount: 4, + } ); + + const expected = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: 'test', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'inherits the `align` property from the first cell in the column when adding a new row', () => { + const tableWithAlignment = { + body: [ + { + cells: [ + { + align: 'right', + content: 'test', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + ], + }; + + const state = insertRow( tableWithAlignment, { + sectionName: 'body', + rowIndex: 1, + } ); + + expect( state ).toEqual( { + body: [ + { + cells: [ + { + align: 'right', + content: 'test', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + { + cells: [ + { + align: 'right', + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + } ); + } ); + + it( 'adds `th` cells to the head', () => { + const state = insertRow( tableWithHead, { + sectionName: 'head', + rowIndex: 1, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: 'test', + tag: 'th', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'should have no effect if `columnCount` is not provided and the table has no existing rows', () => { + const existingState = { body: {} }; + const newState = insertRow( existingState, { + sectionName: 'body', + rowIndex: 0, + } ); + + expect( newState ).toBe( existingState ); + } ); + + it( 'should have no effect if `columnCount` is `0`', () => { + const state = insertRow( tableWithHead, { + sectionName: 'head', + rowIndex: 1, + columnCount: 0, + } ); + + expect( state ).toBe( tableWithHead ); + } ); +} ); + +describe( 'insertColumn', () => { + it( 'inserts before existing content by default', () => { + const state = insertColumn( tableWithHead, { + columnIndex: 0, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + { + content: 'test', + tag: 'th', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'inserts a column for table sections that have existing cells', () => { + const state = insertColumn( tableWithContent, { + columnIndex: 2, + } ); + + const expected = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: 'test', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'adds `th` cells to the head', () => { + const state = insertColumn( tableWithHead, { + columnIndex: 1, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: 'test', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'avoids adding cells to empty rows', () => { + const tableWithEmptyRow = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + { + cells: [], + }, + ], + }; + + const state = insertColumn( tableWithEmptyRow, { + columnIndex: 0, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + { + cells: [], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'adds cells across table sections that already have rows', () => { + const tableWithAllSections = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + const state = insertColumn( tableWithAllSections, { + columnIndex: 1, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'adds cells only to rows that have enough cells when rows have an unequal number of cells', () => { + const tableWithUnequalColumns = { + head: [ + { + cells: [ + { + content: '0', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '0', + tag: 'td', + }, + { + content: '1', + tag: 'td', + }, + { + content: '2', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '0', + tag: 'td', + }, + ], + }, + ], + }; + + const state = insertColumn( tableWithUnequalColumns, { + columnIndex: 3, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: '0', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '0', + tag: 'td', + }, + { + content: '1', + tag: 'td', + }, + { + content: '2', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '0', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); +} ); + +describe( 'deleteRow', () => { + it( 'should delete row', () => { + const state = deleteRow( tableWithContent, { + sectionName: 'body', + rowIndex: 0, + } ); + + const expected = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: 'test', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); +} ); + +describe( 'deleteColumn', () => { + it( 'should delete column', () => { + const state = deleteColumn( tableWithContent, { + columnIndex: 0, + } ); + + const expected = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: 'test', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'should delete all rows when only one column present', () => { + const tableWithOneColumn = { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: 'test', + tag: 'td', + }, + ], + }, + ], + }; + const state = deleteColumn( tableWithOneColumn, { + columnIndex: 0, + } ); + + const expected = { + body: [], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'deletes columns across table sections', () => { + const tableWithOneColumn = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: 'test', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + const state = deleteColumn( tableWithOneColumn, { + columnIndex: 0, + } ); + + const expected = { + head: [], + body: [], + foot: [], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'deletes columns across table sections when there are missing columns', () => { + const tableWithOneColumn = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + const state = deleteColumn( tableWithOneColumn, { + columnIndex: 1, + } ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + ], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + foot: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); +} ); + +describe( 'toggleSection', () => { + it( 'removes rows from the head section if the table already has them', () => { + const state = toggleSection( tableWithHead, 'head' ); + + const expected = { + head: [], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'adds a row to the head section if the table has none', () => { + const tableWithEmptyHead = { + head: [], + }; + + const state = toggleSection( tableWithEmptyHead, 'head' ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); + + it( 'uses the number of cells in the first row of the body for the added table row', () => { + const tableWithEmptyHead = { + head: [], + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }; + + const state = toggleSection( tableWithEmptyHead, 'head' ); + + const expected = { + head: [ + { + cells: [ + { + content: '', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + { + content: '', + tag: 'th', + }, + ], + }, + ], + }; + + expect( state ).toEqual( expected ); + } ); +} ); + +describe( 'isEmptyTableSection', () => { + it( 'considers a section empty if it has no rows', () => { + const tableSection = []; + expect( isEmptyTableSection( tableSection ) ).toBe( true ); + } ); + + it( 'considers a section empty if it has a single row with no cells', () => { + const tableSection = [ + { + cells: [], + }, + ]; + + expect( isEmptyTableSection( tableSection ) ).toBe( true ); + } ); + + it( 'considers a section empty if it has multiple empty rows', () => { + const tableSection = [ + { + cells: [], + }, + { + cells: [], + }, + ]; + + expect( isEmptyTableSection( tableSection ) ).toBe( true ); + } ); + + it( 'considers a section not empty if it has a mixture of empty and non-empty rows', () => { + const tableSection = [ + { + cells: [], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ]; + + expect( isEmptyTableSection( tableSection ) ).toBe( false ); + } ); +} ); + +describe( 'isEmptyRow', () => { + it( 'considers a row empty if it has undefined cells', () => { + expect( isEmptyRow( {} ) ).toBe( true ); + } ); + + it( 'considers a row empty if it has a zero length array of cells', () => { + expect( isEmptyRow( { cells: [] } ) ).toBe( true ); + } ); + + it( 'considers a row not empty if it has a cell', () => { + const row = { + cells: [ + { + content: '', + tag: 'td', + }, + ], + }; + + expect( isEmptyRow( row ) ).toBe( false ); + } ); +} ); + +describe( 'isCellSelected', () => { + it( 'returns false when no cellLocation is provided', () => { + const tableSelection = { type: 'table' }; + + expect( isCellSelected( undefined, tableSelection ) ).toBe( false ); + } ); + + it( 'returns false when no selection is provided', () => { + const cellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + + expect( isCellSelected( cellLocation ) ).toBe( false ); + } ); + + it( `considers only cells with the same columnIndex to be selected when the selection.type is 'column'`, () => { + // Valid locations and selections. + const headCellLocationA = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + const headCellLocationB = { sectionName: 'head', columnIndex: 0, rowIndex: 1 }; + const bodyCellLocationA = { sectionName: 'body', columnIndex: 0, rowIndex: 0 }; + const bodyCellLocationB = { sectionName: 'body', columnIndex: 0, rowIndex: 1 }; + const footCellLocationA = { sectionName: 'foot', columnIndex: 0, rowIndex: 0 }; + const footCellLocationB = { sectionName: 'foot', columnIndex: 0, rowIndex: 1 }; + const columnSelection = { type: 'column', columnIndex: 0 }; + + // Invalid locations and selections. + const otherColumnCellLocationA = { sectionName: 'head', columnIndex: 1, rowIndex: 0 }; + const otherColumnCellLocationB = { sectionName: 'body', columnIndex: 2, rowIndex: 0 }; + const otherColumnCellLocationC = { sectionName: 'foot', columnIndex: 3, rowIndex: 0 }; + + expect( isCellSelected( headCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( headCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( bodyCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( bodyCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( footCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( footCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( otherColumnCellLocationA, columnSelection ) ).toBe( false ); + expect( isCellSelected( otherColumnCellLocationB, columnSelection ) ).toBe( false ); + expect( isCellSelected( otherColumnCellLocationC, columnSelection ) ).toBe( false ); + } ); + + it( `considers only cells with the same section, columnIndex and rowIndex to be selected when the selection.type is 'cell'`, () => { + // Valid locations and selections. + const cellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + const cellSelection = { type: 'cell', sectionName: 'head', rowIndex: 0, columnIndex: 0 }; + + // Invalid locations and selections. + const otherColumnCellLocation = { sectionName: 'head', columnIndex: 1, rowIndex: 0 }; + const otherRowCellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 1 }; + const bodyCellLocation = { sectionName: 'body', columnIndex: 0, rowIndex: 0 }; + const footCellLocation = { sectionName: 'foot', columnIndex: 0, rowIndex: 0 }; + + expect( isCellSelected( cellLocation, cellSelection ) ).toBe( true ); + expect( isCellSelected( otherColumnCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( otherRowCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( bodyCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( footCellLocation, cellSelection ) ).toBe( false ); + } ); +} ); + +describe( 'updateSelectedCell', () => { + it( 'returns an unchanged table state if there is no selection', () => { + const updated = updateSelectedCell( table, undefined, ( cell ) => ( { ...cell, content: 'test' } ) ); + expect( table ).toEqual( updated ); + } ); + + it( 'returns an unchanged table state if the selection is outside the bounds of the table', () => { + const cellSelection = { type: 'cell', sectionName: 'body', rowIndex: 100, columnIndex: 100 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + expect( table ).toEqual( updated ); + } ); + + it( 'updates only the individual cell when the selection type is `cell`', () => { + const cellSelection = { type: 'cell', sectionName: 'body', rowIndex: 0, columnIndex: 0 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + + expect( updated ).toEqual( { + body: [ + { + cells: [ + { + ...table.body[ 0 ].cells[ 0 ], + content: 'test', + }, + table.body[ 0 ].cells[ 1 ], + ], + }, + table.body[ 1 ], + ], + } ); + } ); + + it( 'updates every cell in the column when the selection type is `column`', () => { + const cellSelection = { type: 'column', columnIndex: 1 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + + expect( updated ).toEqual( { + body: [ + { + cells: [ + table.body[ 0 ].cells[ 0 ], + { + ...table.body[ 0 ].cells[ 1 ], + content: 'test', + }, + ], + }, + { + cells: [ + table.body[ 1 ].cells[ 0 ], + { + ...table.body[ 1 ].cells[ 1 ], + content: 'test', + }, + ], + }, + ], + } ); + } ); +} ); diff --git a/gb-src/packages/block-library/src/table/theme.scss b/gb-src/packages/block-library/src/table/theme.scss new file mode 100644 index 0000000000000..664a4f57cf53d --- /dev/null +++ b/gb-src/packages/block-library/src/table/theme.scss @@ -0,0 +1,10 @@ +.wp-block-table { + border-collapse: collapse; + + td, + th { + padding: 0.5em; + border: 1px solid; + word-break: normal; + } +} diff --git a/gb-src/packages/block-library/src/table/transforms.js b/gb-src/packages/block-library/src/table/transforms.js new file mode 100644 index 0000000000000..e002dec221915 --- /dev/null +++ b/gb-src/packages/block-library/src/table/transforms.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { getPhrasingContentSchema } from '@wordpress/blocks'; + +const tableContentPasteSchema = { + tr: { + allowEmpty: true, + children: { + th: { + allowEmpty: true, + children: getPhrasingContentSchema(), + attributes: [ 'scope' ], + }, + td: { + allowEmpty: true, + children: getPhrasingContentSchema(), + }, + }, + }, +}; + +const tablePasteSchema = { + table: { + children: { + thead: { + allowEmpty: true, + children: tableContentPasteSchema, + }, + tfoot: { + allowEmpty: true, + children: tableContentPasteSchema, + }, + tbody: { + allowEmpty: true, + children: tableContentPasteSchema, + }, + }, + }, +}; + +const transforms = { + from: [ + { + type: 'raw', + selector: 'table', + schema: tablePasteSchema, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/tag-cloud/edit.js b/gb-src/packages/block-library/src/tag-cloud/edit.js new file mode 100644 index 0000000000000..2cda30f52e2c3 --- /dev/null +++ b/gb-src/packages/block-library/src/tag-cloud/edit.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { map, filter } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + PanelBody, + ToggleControl, + SelectControl, +} from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import ServerSideRender from '@wordpress/server-side-render'; + +class TagCloudEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + editing: ! this.props.attributes.taxonomy, + }; + + this.setTaxonomy = this.setTaxonomy.bind( this ); + this.toggleShowTagCounts = this.toggleShowTagCounts.bind( this ); + } + + getTaxonomyOptions() { + const taxonomies = filter( this.props.taxonomies, 'show_cloud' ); + const selectOption = { + label: __( '- Select -' ), + value: '', + disabled: true, + }; + const taxonomyOptions = map( taxonomies, ( taxonomy ) => { + return { + value: taxonomy.slug, + label: taxonomy.name, + }; + } ); + + return [ selectOption, ...taxonomyOptions ]; + } + + setTaxonomy( taxonomy ) { + const { setAttributes } = this.props; + + setAttributes( { taxonomy } ); + } + + toggleShowTagCounts() { + const { attributes, setAttributes } = this.props; + const { showTagCounts } = attributes; + + setAttributes( { showTagCounts: ! showTagCounts } ); + } + + render() { + const { attributes } = this.props; + const { taxonomy, showTagCounts } = attributes; + const taxonomyOptions = this.getTaxonomyOptions(); + + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ __( 'Tag Cloud Settings' ) }> + <SelectControl + label={ __( 'Taxonomy' ) } + options={ taxonomyOptions } + value={ taxonomy } + onChange={ this.setTaxonomy } + /> + <ToggleControl + label={ __( 'Show post counts' ) } + checked={ showTagCounts } + onChange={ this.toggleShowTagCounts } + /> + </PanelBody> + </InspectorControls> + ); + + return ( + <> + { inspectorControls } + <ServerSideRender + key="tag-cloud" + block="core/tag-cloud" + attributes={ attributes } + /> + </> + ); + } +} + +export default withSelect( ( select ) => { + return { + taxonomies: select( 'core' ).getTaxonomies(), + }; +} )( TagCloudEdit ); diff --git a/gb-src/packages/block-library/src/tag-cloud/editor.scss b/gb-src/packages/block-library/src/tag-cloud/editor.scss new file mode 100644 index 0000000000000..c70f152286080 --- /dev/null +++ b/gb-src/packages/block-library/src/tag-cloud/editor.scss @@ -0,0 +1,13 @@ +.block-editor .wp-block-tag-cloud { + a { + display: inline-block; + margin-right: 5px; + } + + span { + display: inline-block; + margin-left: 5px; + color: $dark-gray-100; + text-decoration: none; + } +} diff --git a/gb-src/packages/block-library/src/tag-cloud/index.js b/gb-src/packages/block-library/src/tag-cloud/index.js new file mode 100644 index 0000000000000..a610875b23388 --- /dev/null +++ b/gb-src/packages/block-library/src/tag-cloud/index.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; + +export const name = 'core/tag-cloud'; + +export const settings = { + title: __( 'Tag Cloud' ), + description: __( 'A cloud of your most used tags.' ), + icon: 'tag', + category: 'widgets', + supports: { + html: false, + align: true, + }, + edit, +}; diff --git a/gb-src/packages/block-library/src/tag-cloud/index.php b/gb-src/packages/block-library/src/tag-cloud/index.php new file mode 100644 index 0000000000000..cf9c4d24537db --- /dev/null +++ b/gb-src/packages/block-library/src/tag-cloud/index.php @@ -0,0 +1,78 @@ +<?php +/** + * Server-side rendering of the `core/tag-cloud` block. + * + * @package WordPress + */ + +/** + * Renders the `core/tag-cloud` block on server. + * + * @param array $attributes The block attributes. + * + * @return string Returns the tag cloud for selected taxonomy. + */ +function render_block_core_tag_cloud( $attributes ) { + $class = isset( $attributes['align'] ) ? + "wp-block-tag-cloud align{$attributes['align']}" : + 'wp-block-tag-cloud'; + + if ( isset( $attributes['className'] ) ) { + $class .= ' ' . $attributes['className']; + } + + $args = array( + 'echo' => false, + 'taxonomy' => $attributes['taxonomy'], + 'show_count' => $attributes['showTagCounts'], + ); + + $tag_cloud = wp_tag_cloud( $args ); + + if ( ! $tag_cloud ) { + $labels = get_taxonomy_labels( get_taxonomy( $attributes['taxonomy'] ) ); + $tag_cloud = esc_html( + sprintf( + /* translators: %s: taxonomy name */ + __( 'Your site doesn’t have any %s, so there’s nothing to display here at the moment.' ), + strtolower( $labels->name ) + ) + ); + } + + return sprintf( + '<p class="%1$s">%2$s</p>', + esc_attr( $class ), + $tag_cloud + ); +} + +/** + * Registers the `core/tag-cloud` block on server. + */ +function register_block_core_tag_cloud() { + register_block_type( + 'core/tag-cloud', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + 'className' => array( + 'type' => 'string', + ), + 'taxonomy' => array( + 'type' => 'string', + 'default' => 'post_tag', + ), + 'showTagCounts' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'render_callback' => 'render_block_core_tag_cloud', + ) + ); +} +add_action( 'init', 'register_block_core_tag_cloud' ); diff --git a/gb-src/packages/block-library/src/text-columns/block.json b/gb-src/packages/block-library/src/text-columns/block.json new file mode 100644 index 0000000000000..cf86b62ec572c --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/block.json @@ -0,0 +1,26 @@ +{ + "name": "core/text-columns", + "icon": "columns", + "category": "layout", + "attributes": { + "content": { + "type": "array", + "source": "query", + "selector": "p", + "query": { + "children": { + "type": "string", + "source": "html" + } + }, + "default": [ {}, {} ] + }, + "columns": { + "type": "number", + "default": 2 + }, + "width": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/text-columns/edit.js b/gb-src/packages/block-library/src/text-columns/edit.js new file mode 100644 index 0000000000000..ec3368acdeccc --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/edit.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { get, times } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { PanelBody, RangeControl } from '@wordpress/components'; +import { + BlockControls, + BlockAlignmentToolbar, + InspectorControls, + RichText, +} from '@wordpress/block-editor'; +import deprecated from '@wordpress/deprecated'; + +export default function TextColumnsEdit( { attributes, setAttributes, className } ) { + const { width, content, columns } = attributes; + + deprecated( 'The Text Columns block', { + alternative: 'the Columns block', + plugin: 'Gutenberg', + } ); + + return ( + <> + <BlockControls> + <BlockAlignmentToolbar + value={ width } + onChange={ ( nextWidth ) => setAttributes( { width: nextWidth } ) } + controls={ [ 'center', 'wide', 'full' ] } + /> + </BlockControls> + <InspectorControls> + <PanelBody> + <RangeControl + label={ __( 'Columns' ) } + value={ columns } + onChange={ ( value ) => setAttributes( { columns: value } ) } + min={ 2 } + max={ 4 } + required + /> + </PanelBody> + </InspectorControls> + <div className={ `${ className } align${ width } columns-${ columns }` }> + { times( columns, ( index ) => { + return ( + <div className="wp-block-column" key={ `column-${ index }` }> + <RichText + tagName="p" + value={ get( content, [ index, 'children' ] ) } + onChange={ ( nextContent ) => { + setAttributes( { + content: [ + ...content.slice( 0, index ), + { children: nextContent }, + ...content.slice( index + 1 ), + ], + } ); + } } + placeholder={ __( 'New Column' ) } + /> + </div> + ); + } ) } + </div> + </> + ); +} diff --git a/gb-src/packages/block-library/src/text-columns/editor.scss b/gb-src/packages/block-library/src/text-columns/editor.scss new file mode 100644 index 0000000000000..94eee7497b8f8 --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/editor.scss @@ -0,0 +1,5 @@ +.wp-block-text-columns { + .block-editor-rich-text__editable:focus { + outline: $border-width solid $light-gray-500; + } +} diff --git a/gb-src/packages/block-library/src/text-columns/index.js b/gb-src/packages/block-library/src/text-columns/index.js new file mode 100644 index 0000000000000..1e266f1516a24 --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/index.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import transforms from './tranforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + // Disable insertion as this block is deprecated and ultimately replaced by the Columns block. + supports: { + inserter: false, + }, + title: __( 'Text Columns (deprecated)' ), + description: __( 'This block is deprecated. Please use the Columns block instead.' ), + transforms, + getEditWrapperProps( attributes ) { + const { width } = attributes; + if ( 'wide' === width || 'full' === width ) { + return { 'data-align': width }; + } + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/text-columns/save.js b/gb-src/packages/block-library/src/text-columns/save.js new file mode 100644 index 0000000000000..4952c163fc208 --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/save.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { get, times } from 'lodash'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { width, content, columns } = attributes; + return ( + <div className={ `align${ width } columns-${ columns }` }> + { times( columns, ( index ) => + <div className="wp-block-column" key={ `column-${ index }` }> + <RichText.Content tagName="p" value={ get( content, [ index, 'children' ] ) } /> + </div> + ) } + </div> + ); +} diff --git a/gb-src/packages/block-library/src/text-columns/style.scss b/gb-src/packages/block-library/src/text-columns/style.scss new file mode 100644 index 0000000000000..63f0ac4e6f669 --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/style.scss @@ -0,0 +1,30 @@ +.wp-block-text-columns { + display: flex; + + &.aligncenter { + display: flex; + } + + .wp-block-column { + margin: 0 16px; + padding: 0; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + } + + &.columns-2 .wp-block-column { + width: calc(100% / 2); + } + &.columns-3 .wp-block-column { + width: calc(100% / 3); + } + &.columns-4 .wp-block-column { + width: calc(100% / 4); + } +} diff --git a/gb-src/packages/block-library/src/text-columns/tranforms.js b/gb-src/packages/block-library/src/text-columns/tranforms.js new file mode 100644 index 0000000000000..050e49c65bfa0 --- /dev/null +++ b/gb-src/packages/block-library/src/text-columns/tranforms.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + to: [ + { + type: 'block', + blocks: [ 'core/columns' ], + transform: ( { className, columns, content, width } ) => ( + createBlock( + 'core/columns', + { + align: ( 'wide' === width || 'full' === width ) ? width : undefined, + className, + columns, + }, + content.map( ( { children } ) => + createBlock( + 'core/column', + {}, + [ createBlock( 'core/paragraph', { content: children } ) ] + ) + ) + ) + ), + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/theme.scss b/gb-src/packages/block-library/src/theme.scss new file mode 100644 index 0000000000000..57eaa3a710d07 --- /dev/null +++ b/gb-src/packages/block-library/src/theme.scss @@ -0,0 +1,13 @@ +@import "./audio/theme.scss"; +@import "./code/theme.scss"; +@import "./embed/theme.scss"; +@import "./gallery/theme.scss"; +@import "./image/theme.scss"; +@import "./preformatted/theme.scss"; +@import "./pullquote/theme.scss"; +@import "./quote/theme.scss"; +@import "./search/theme.scss"; +@import "./group/theme.scss"; +@import "./separator/theme.scss"; +@import "./table/theme.scss"; +@import "./video/theme.scss"; diff --git a/gb-src/packages/block-library/src/verse/block.json b/gb-src/packages/block-library/src/verse/block.json new file mode 100644 index 0000000000000..d0def4bb7b74f --- /dev/null +++ b/gb-src/packages/block-library/src/verse/block.json @@ -0,0 +1,15 @@ +{ + "name": "core/verse", + "category": "formatting", + "attributes": { + "content": { + "type": "string", + "source": "html", + "selector": "pre", + "default": "" + }, + "textAlign": { + "type": "string" + } + } +} diff --git a/gb-src/packages/block-library/src/verse/deprecated.js b/gb-src/packages/block-library/src/verse/deprecated.js new file mode 100644 index 0000000000000..f26ac103c226b --- /dev/null +++ b/gb-src/packages/block-library/src/verse/deprecated.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +const blockAttributes = { + content: { + type: 'string', + source: 'html', + selector: 'pre', + default: '', + }, + textAlign: { + type: 'string', + }, +}; + +const deprecated = [ + { + attributes: blockAttributes, + save( { attributes } ) { + const { textAlign, content } = attributes; + + return ( + <RichText.Content + tagName="pre" + style={ { textAlign } } + value={ content } + /> + ); + }, + }, +]; + +export default deprecated; diff --git a/gb-src/packages/block-library/src/verse/edit.js b/gb-src/packages/block-library/src/verse/edit.js new file mode 100644 index 0000000000000..ff0beac6622fc --- /dev/null +++ b/gb-src/packages/block-library/src/verse/edit.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + RichText, + BlockControls, + AlignmentToolbar, +} from '@wordpress/block-editor'; + +export default function VerseEdit( { attributes, setAttributes, className, mergeBlocks } ) { + const { textAlign, content } = attributes; + + return ( + <> + <BlockControls> + <AlignmentToolbar + value={ textAlign } + onChange={ ( nextAlign ) => { + setAttributes( { textAlign: nextAlign } ); + } } + /> + </BlockControls> + <RichText + tagName="pre" + value={ content } + onChange={ ( nextContent ) => { + setAttributes( { + content: nextContent, + } ); + } } + placeholder={ __( 'Write…' ) } + wrapperClassName={ className } + className={ classnames( { + [ `has-text-align-${ textAlign }` ]: textAlign, + } ) } + onMerge={ mergeBlocks } + /> + </> + ); +} diff --git a/gb-src/packages/block-library/src/verse/editor.scss b/gb-src/packages/block-library/src/verse/editor.scss new file mode 100644 index 0000000000000..075076ad72af4 --- /dev/null +++ b/gb-src/packages/block-library/src/verse/editor.scss @@ -0,0 +1,9 @@ +pre.wp-block-verse, +.wp-block-verse pre { + color: $dark-gray-900; + white-space: nowrap; + font-family: inherit; + font-size: inherit; + padding: 1em; + overflow: auto; +} diff --git a/gb-src/packages/block-library/src/verse/icon.js b/gb-src/packages/block-library/src/verse/icon.js new file mode 100644 index 0000000000000..4e665bdaa68e0 --- /dev/null +++ b/gb-src/packages/block-library/src/verse/icon.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M21 11.01L3 11V13H21V11.01ZM3 16H17V18H3V16ZM15 6H3V8.01L15 8V6Z" /></SVG> +); diff --git a/gb-src/packages/block-library/src/verse/index.js b/gb-src/packages/block-library/src/verse/index.js new file mode 100644 index 0000000000000..4bf42fea4b945 --- /dev/null +++ b/gb-src/packages/block-library/src/verse/index.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import deprecated from './deprecated'; +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './tranforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Verse' ), + description: __( 'Insert poetry. Use special spacing formats. Or quote song lyrics.' ), + icon, + example: { + attributes: { + content: __( 'WHAT was he doing, the great god Pan,' ) + '<br>' + + __( ' Down in the reeds by the river?' ) + '<br>' + + __( 'Spreading ruin and scattering ban,' ) + '<br>' + + __( 'Splashing and paddling with hoofs of a goat,' ) + '<br>' + + __( 'And breaking the golden lilies afloat' ) + '<br>' + + __( ' With the dragon-fly on the river.' ), + }, + }, + keywords: [ __( 'poetry' ) ], + transforms, + deprecated, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + attributesToMerge.content, + }; + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/verse/save.js b/gb-src/packages/block-library/src/verse/save.js new file mode 100644 index 0000000000000..59bb855746269 --- /dev/null +++ b/gb-src/packages/block-library/src/verse/save.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { textAlign, content } = attributes; + + const className = classnames( { + [ `has-text-align-${ textAlign }` ]: textAlign, + } ); + + return ( + <RichText.Content + tagName="pre" + className={ className } + value={ content } + /> + ); +} diff --git a/gb-src/packages/block-library/src/verse/style.scss b/gb-src/packages/block-library/src/verse/style.scss new file mode 100644 index 0000000000000..8d2447ca82af5 --- /dev/null +++ b/gb-src/packages/block-library/src/verse/style.scss @@ -0,0 +1,4 @@ +pre.wp-block-verse { + white-space: nowrap; + overflow: auto; +} diff --git a/gb-src/packages/block-library/src/verse/tranforms.js b/gb-src/packages/block-library/src/verse/tranforms.js new file mode 100644 index 0000000000000..240b1ac023e1d --- /dev/null +++ b/gb-src/packages/block-library/src/verse/tranforms.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( attributes ) => + createBlock( 'core/verse', attributes ), + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( attributes ) => + createBlock( 'core/paragraph', attributes ), + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/video/block.json b/gb-src/packages/block-library/src/video/block.json new file mode 100644 index 0000000000000..c62179b360d2b --- /dev/null +++ b/gb-src/packages/block-library/src/video/block.json @@ -0,0 +1,64 @@ +{ + "name": "core/video", + "category": "common", + "attributes": { + "autoplay": { + "type": "boolean", + "source": "attribute", + "selector": "video", + "attribute": "autoplay" + }, + "caption": { + "type": "string", + "source": "html", + "selector": "figcaption" + }, + "controls": { + "type": "boolean", + "source": "attribute", + "selector": "video", + "attribute": "controls", + "default": true + }, + "id": { + "type": "number" + }, + "loop": { + "type": "boolean", + "source": "attribute", + "selector": "video", + "attribute": "loop" + }, + "muted": { + "type": "boolean", + "source": "attribute", + "selector": "video", + "attribute": "muted" + }, + "poster": { + "type": "string", + "source": "attribute", + "selector": "video", + "attribute": "poster" + }, + "preload": { + "type": "string", + "source": "attribute", + "selector": "video", + "attribute": "preload", + "default": "metadata" + }, + "src": { + "type": "string", + "source": "attribute", + "selector": "video", + "attribute": "src" + }, + "playsInline": { + "type": "boolean", + "source": "attribute", + "selector": "video", + "attribute": "playsinline" + } + } +} diff --git a/gb-src/packages/block-library/src/video/edit.js b/gb-src/packages/block-library/src/video/edit.js new file mode 100644 index 0000000000000..6236e51a95c93 --- /dev/null +++ b/gb-src/packages/block-library/src/video/edit.js @@ -0,0 +1,329 @@ +/** + * WordPress dependencies + */ +import { getBlobByURL, isBlobURL } from '@wordpress/blob'; +import { + BaseControl, + Button, + Disabled, + IconButton, + PanelBody, + SelectControl, + ToggleControl, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { + BlockControls, + BlockIcon, + InspectorControls, + MediaPlaceholder, + MediaUpload, + MediaUploadCheck, + RichText, +} from '@wordpress/block-editor'; +import { Component, createRef } from '@wordpress/element'; +import { + __, + sprintf, +} from '@wordpress/i18n'; +import { + compose, + withInstanceId, +} from '@wordpress/compose'; +import { + withSelect, +} from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { createUpgradedEmbedBlock } from '../embed/util'; +import icon from './icon'; + +const ALLOWED_MEDIA_TYPES = [ 'video' ]; +const VIDEO_POSTER_ALLOWED_MEDIA_TYPES = [ 'image' ]; + +class VideoEdit extends Component { + constructor() { + super( ...arguments ); + // edit component has its own src in the state so it can be edited + // without setting the actual value outside of the edit UI + this.state = { + editing: ! this.props.attributes.src, + }; + + this.videoPlayer = createRef(); + this.posterImageButton = createRef(); + this.toggleAttribute = this.toggleAttribute.bind( this ); + this.onSelectURL = this.onSelectURL.bind( this ); + this.onSelectPoster = this.onSelectPoster.bind( this ); + this.onRemovePoster = this.onRemovePoster.bind( this ); + this.onUploadError = this.onUploadError.bind( this ); + } + + componentDidMount() { + const { + attributes, + mediaUpload, + noticeOperations, + setAttributes, + } = this.props; + const { id, src = '' } = attributes; + if ( ! id && isBlobURL( src ) ) { + const file = getBlobByURL( src ); + if ( file ) { + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ { url } ] ) => { + setAttributes( { src: url } ); + }, + onError: ( message ) => { + this.setState( { editing: true } ); + noticeOperations.createErrorNotice( message ); + }, + allowedTypes: ALLOWED_MEDIA_TYPES, + } ); + } + } + } + + componentDidUpdate( prevProps ) { + if ( this.props.attributes.poster !== prevProps.attributes.poster ) { + this.videoPlayer.current.load(); + } + } + + toggleAttribute( attribute ) { + return ( newValue ) => { + this.props.setAttributes( { [ attribute ]: newValue } ); + }; + } + + onSelectURL( newSrc ) { + const { attributes, setAttributes } = this.props; + const { src } = attributes; + + // Set the block's src from the edit component's state, and switch off + // the editing UI. + if ( newSrc !== src ) { + // Check if there's an embed block that handles this URL. + const embedBlock = createUpgradedEmbedBlock( + { attributes: { url: newSrc } } + ); + if ( undefined !== embedBlock ) { + this.props.onReplace( embedBlock ); + return; + } + setAttributes( { src: newSrc, id: undefined } ); + } + + this.setState( { editing: false } ); + } + + onSelectPoster( image ) { + const { setAttributes } = this.props; + setAttributes( { poster: image.url } ); + } + + onRemovePoster() { + const { setAttributes } = this.props; + setAttributes( { poster: '' } ); + + // Move focus back to the Media Upload button. + this.posterImageButton.current.focus(); + } + + onUploadError( message ) { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + getAutoplayHelp( checked ) { + return checked ? __( 'Note: Autoplaying videos may cause usability issues for some visitors.' ) : null; + } + + render() { + const { + autoplay, + caption, + controls, + loop, + muted, + playsInline, + poster, + preload, + src, + } = this.props.attributes; + const { + className, + instanceId, + isSelected, + noticeUI, + setAttributes, + } = this.props; + const { editing } = this.state; + const switchToEditing = () => { + this.setState( { editing: true } ); + }; + const onSelectVideo = ( media ) => { + if ( ! media || ! media.url ) { + // in this case there was an error and we should continue in the editing state + // previous attributes should be removed because they may be temporary blob urls + setAttributes( { src: undefined, id: undefined } ); + switchToEditing(); + return; + } + // sets the block's attribute and updates the edit component from the + // selected media, then switches off the editing UI + setAttributes( { src: media.url, id: media.id } ); + this.setState( { src: media.url, editing: false } ); + }; + + if ( editing ) { + return ( + <MediaPlaceholder + icon={ <BlockIcon icon={ icon } /> } + className={ className } + onSelect={ onSelectVideo } + onSelectURL={ this.onSelectURL } + accept="video/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ this.props.attributes } + notices={ noticeUI } + onError={ this.onUploadError } + /> + ); + } + const videoPosterDescription = `video-block__poster-image-description-${ instanceId }`; + + return ( + <> + <BlockControls> + <Toolbar> + <IconButton + className="components-icon-button components-toolbar__control" + label={ __( 'Edit video' ) } + onClick={ switchToEditing } + icon="edit" + /> + </Toolbar> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Video Settings' ) }> + <ToggleControl + label={ __( 'Autoplay' ) } + onChange={ this.toggleAttribute( 'autoplay' ) } + checked={ autoplay } + help={ this.getAutoplayHelp } + /> + <ToggleControl + label={ __( 'Loop' ) } + onChange={ this.toggleAttribute( 'loop' ) } + checked={ loop } + /> + <ToggleControl + label={ __( 'Muted' ) } + onChange={ this.toggleAttribute( 'muted' ) } + checked={ muted } + /> + <ToggleControl + label={ __( 'Playback Controls' ) } + onChange={ this.toggleAttribute( 'controls' ) } + checked={ controls } + /> + <ToggleControl + label={ __( 'Play inline' ) } + onChange={ this.toggleAttribute( 'playsInline' ) } + checked={ playsInline } + /> + <SelectControl + label={ __( 'Preload' ) } + value={ preload } + onChange={ ( value ) => setAttributes( { preload: value } ) } + options={ [ + { value: 'auto', label: __( 'Auto' ) }, + { value: 'metadata', label: __( 'Metadata' ) }, + { value: 'none', label: __( 'None' ) }, + ] } + /> + <MediaUploadCheck> + <BaseControl + className="editor-video-poster-control" + > + <BaseControl.VisualLabel> + { __( 'Poster Image' ) } + </BaseControl.VisualLabel> + <MediaUpload + title={ __( 'Select Poster Image' ) } + onSelect={ this.onSelectPoster } + allowedTypes={ VIDEO_POSTER_ALLOWED_MEDIA_TYPES } + render={ ( { open } ) => ( + <Button + isDefault + onClick={ open } + ref={ this.posterImageButton } + aria-describedby={ videoPosterDescription } + > + { ! this.props.attributes.poster ? __( 'Select Poster Image' ) : __( 'Replace image' ) } + </Button> + ) } + /> + <p + id={ videoPosterDescription } + hidden + > + { this.props.attributes.poster ? + sprintf( __( 'The current poster image url is %s' ), this.props.attributes.poster ) : + __( 'There is no poster image currently selected' ) + } + </p> + { !! this.props.attributes.poster && + <Button onClick={ this.onRemovePoster } isLink isDestructive> + { __( 'Remove Poster Image' ) } + </Button> + } + </BaseControl> + </MediaUploadCheck> + </PanelBody> + </InspectorControls> + <figure className={ className }> + { /* + Disable the video tag so the user clicking on it won't play the + video when the controls are enabled. + */ } + <Disabled> + <video + controls={ controls } + poster={ poster } + src={ src } + ref={ this.videoPlayer } + /> + </Disabled> + { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + onChange={ ( value ) => setAttributes( { caption: value } ) } + inlineToolbar + /> + ) } + </figure> + </> + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + const { __experimentalMediaUpload } = getSettings(); + return { + mediaUpload: __experimentalMediaUpload, + }; + } ), + withNotices, + withInstanceId, +] )( VideoEdit ); diff --git a/gb-src/packages/block-library/src/video/edit.native.js b/gb-src/packages/block-library/src/video/edit.native.js new file mode 100644 index 0000000000000..3b0a9d186d308 --- /dev/null +++ b/gb-src/packages/block-library/src/video/edit.native.js @@ -0,0 +1,265 @@ +/** + * External dependencies + */ +import React from 'react'; +import { View, TouchableWithoutFeedback, Text } from 'react-native'; +/** + * Internal dependencies + */ +import Video from './video-player'; +import { + mediaUploadSync, + requestImageFailedRetryDialog, + requestImageUploadCancelDialog, +} from 'react-native-gutenberg-bridge'; + +/** + * WordPress dependencies + */ +import { + Icon, + Toolbar, + ToolbarButton, +} from '@wordpress/components'; +import { + Caption, + MediaPlaceholder, + MediaUpload, + MEDIA_TYPE_VIDEO, + BlockControls, + InspectorControls, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { isURL } from '@wordpress/url'; +import { doAction, hasAction } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import MediaUploadProgress from '../image/media-upload-progress'; +import style from './style.scss'; +import SvgIcon from './icon'; +import SvgIconRetry from './icon-retry'; + +const VIDEO_ASPECT_RATIO = 1.7; + +class VideoEdit extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + isCaptionSelected: false, + showSettings: false, + videoContainerHeight: 0, + }; + + this.mediaUploadStateReset = this.mediaUploadStateReset.bind( this ); + this.onSelectMediaUploadOption = this.onSelectMediaUploadOption.bind( this ); + this.finishMediaUploadWithSuccess = this.finishMediaUploadWithSuccess.bind( this ); + this.finishMediaUploadWithFailure = this.finishMediaUploadWithFailure.bind( this ); + this.updateMediaProgress = this.updateMediaProgress.bind( this ); + this.onVideoPressed = this.onVideoPressed.bind( this ); + this.onVideoContanerLayout = this.onVideoContanerLayout.bind( this ); + this.onFocusCaption = this.onFocusCaption.bind( this ); + } + + componentDidMount() { + const { attributes } = this.props; + if ( attributes.id && ! isURL( attributes.src ) ) { + mediaUploadSync(); + } + } + + componentWillUnmount() { + // this action will only exist if the user pressed the trash button on the block holder + if ( hasAction( 'blocks.onRemoveBlockCheckUpload' ) && this.state.isUploadInProgress ) { + doAction( 'blocks.onRemoveBlockCheckUpload', this.props.attributes.id ); + } + } + + static getDerivedStateFromProps( props, state ) { + // Avoid a UI flicker in the toolbar by insuring that isCaptionSelected + // is updated immediately any time the isSelected prop becomes false + return { + isCaptionSelected: props.isSelected && state.isCaptionSelected, + }; + } + + onVideoPressed() { + const { attributes } = this.props; + + if ( this.state.isUploadInProgress ) { + requestImageUploadCancelDialog( attributes.id ); + } else if ( attributes.id && ! isURL( attributes.src ) ) { + requestImageFailedRetryDialog( attributes.id ); + } + + this.setState( { + isCaptionSelected: false, + } ); + } + + onFocusCaption() { + if ( ! this.state.isCaptionSelected ) { + this.setState( { isCaptionSelected: true } ); + } + } + + updateMediaProgress( payload ) { + const { setAttributes } = this.props; + if ( payload.mediaUrl ) { + setAttributes( { url: payload.mediaUrl } ); + } + if ( ! this.state.isUploadInProgress ) { + this.setState( { isUploadInProgress: true } ); + } + } + + finishMediaUploadWithSuccess( payload ) { + const { setAttributes } = this.props; + setAttributes( { src: payload.mediaUrl, id: payload.mediaServerId } ); + this.setState( { isUploadInProgress: false } ); + } + + finishMediaUploadWithFailure( payload ) { + const { setAttributes } = this.props; + setAttributes( { id: payload.mediaId } ); + this.setState( { isUploadInProgress: false } ); + } + + mediaUploadStateReset() { + const { setAttributes } = this.props; + setAttributes( { id: null, src: null } ); + this.setState( { isUploadInProgress: false } ); + } + + onSelectMediaUploadOption( mediaId, mediaUrl ) { + const { setAttributes } = this.props; + setAttributes( { id: mediaId, src: mediaUrl } ); + } + + onVideoContanerLayout( event ) { + const { width } = event.nativeEvent.layout; + const height = width / VIDEO_ASPECT_RATIO; + if ( height !== this.state.videoContainerHeight ) { + this.setState( { videoContainerHeight: height } ); + } + } + + getIcon( isRetryIcon, isMediaPlaceholder ) { + if ( isRetryIcon ) { + return <Icon icon={ SvgIconRetry } { ...style.icon } />; + } + + return <Icon icon={ SvgIcon } { ...( ! isMediaPlaceholder ? style.iconUploading : style.icon ) } />; + } + + render() { + const { attributes, isSelected } = this.props; + const { id, src } = attributes; + const { videoContainerHeight } = this.state; + + const toolbarEditButton = ( + <MediaUpload mediaType={ MEDIA_TYPE_VIDEO } + onSelectURL={ this.onSelectMediaUploadOption } + render={ ( { open, getMediaOptions } ) => { + return ( + <Toolbar> + { getMediaOptions() } + <ToolbarButton + label={ __( 'Edit video' ) } + icon="edit" + onClick={ open } + /> + </Toolbar> + ); + } } > + </MediaUpload> + ); + + if ( ! id ) { + return ( + <View style={ { flex: 1 } } > + <MediaPlaceholder + mediaType={ MEDIA_TYPE_VIDEO } + onSelectURL={ this.onSelectMediaUploadOption } + icon={ this.getIcon( false, true ) } + onFocus={ this.props.onFocus } + /> + </View> + ); + } + + return ( + <TouchableWithoutFeedback onPress={ this.onVideoPressed } disabled={ ! isSelected }> + <View style={ { flex: 1 } }> + { ! this.state.isCaptionSelected && + <BlockControls> + { toolbarEditButton } + </BlockControls> } + <InspectorControls> + { false && <ToolbarButton //Not rendering settings button until it has an action + label={ __( 'Video Settings' ) } + icon="admin-generic" + onClick={ () => ( null ) } + /> } + </InspectorControls> + <MediaUploadProgress + mediaId={ id } + onFinishMediaUploadWithSuccess={ this.finishMediaUploadWithSuccess } + onFinishMediaUploadWithFailure={ this.finishMediaUploadWithFailure } + onUpdateMediaProgress={ this.updateMediaProgress } + onMediaUploadStateReset={ this.mediaUploadStateReset } + renderContent={ ( { isUploadInProgress, isUploadFailed, retryMessage } ) => { + const showVideo = isURL( src ) && ! isUploadInProgress && ! isUploadFailed; + const icon = this.getIcon( isUploadFailed, false ); + const styleIconContainer = isUploadFailed ? style.modalIconRetry : style.modalIcon; + + const iconContainer = ( + <View style={ styleIconContainer }> + { icon } + </View> + ); + + const videoStyle = { + height: videoContainerHeight, + ...style.video, + }; + + const containerStyle = showVideo && isSelected ? style.containerFocused : style.container; + + return ( + <View onLayout={ this.onVideoContanerLayout } style={ containerStyle }> + { showVideo && + <View style={ style.videoContainer }> + <Video + isSelected={ isSelected && ! this.state.isCaptionSelected } + style={ videoStyle } + source={ { uri: src } } + paused={ true } + /> + </View> + } + { ! showVideo && + <View style={ { height: videoContainerHeight, width: '100%', ...style.placeholder } }> + { videoContainerHeight > 0 && iconContainer } + { isUploadFailed && <Text style={ style.uploadFailedText }>{ retryMessage }</Text> } + </View> + } + </View> + ); + } } + /> + <Caption + clientId={ this.props.clientId } + isSelected={ this.state.isCaptionSelected } + onFocus={ this.onFocusCaption } + onBlur={ this.props.onBlur } // always assign onBlur as props + /> + </View> + </TouchableWithoutFeedback> + ); + } +} + +export default VideoEdit; diff --git a/gb-src/packages/block-library/src/video/editor.scss b/gb-src/packages/block-library/src/video/editor.scss new file mode 100644 index 0000000000000..ed7b70c1278dc --- /dev/null +++ b/gb-src/packages/block-library/src/video/editor.scss @@ -0,0 +1,12 @@ +.block-editor-block-list__block[data-align="center"] { + text-align: center; +} + +.editor-video-poster-control .components-button { + display: block; + margin-right: 8px; +} + +.editor-video-poster-control .components-button + .components-button { + margin-top: 1em; +} diff --git a/gb-src/packages/block-library/src/video/gridicon-play.native.js b/gb-src/packages/block-library/src/video/gridicon-play.native.js new file mode 100644 index 0000000000000..6f14d04f16a29 --- /dev/null +++ b/gb-src/packages/block-library/src/video/gridicon-play.native.js @@ -0,0 +1,7 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm-2 14.5v-9l6 4.5z" fill="white" /></SVG>; + diff --git a/gb-src/packages/block-library/src/video/icon-retry.native.js b/gb-src/packages/block-library/src/video/icon-retry.native.js new file mode 100644 index 0000000000000..d56dff2cac124 --- /dev/null +++ b/gb-src/packages/block-library/src/video/icon-retry.native.js @@ -0,0 +1,7 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /><Path d="M0 0h24v24H0z" fill="none" /></SVG>; + diff --git a/gb-src/packages/block-library/src/video/icon.js b/gb-src/packages/block-library/src/video/icon.js new file mode 100644 index 0000000000000..8c43e80279f78 --- /dev/null +++ b/gb-src/packages/block-library/src/video/icon.js @@ -0,0 +1,7 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><Path d="M4 6.47L5.76 10H20v8H4V6.47M22 4h-4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4z" /></SVG>; + diff --git a/gb-src/packages/block-library/src/video/index.js b/gb-src/packages/block-library/src/video/index.js new file mode 100644 index 0000000000000..3eea3386b64e1 --- /dev/null +++ b/gb-src/packages/block-library/src/video/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import icon from './icon'; +import metadata from './block.json'; +import save from './save'; +import transforms from './tranforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Video' ), + description: __( 'Embed a video from your media library or upload a new one.' ), + icon, + keywords: [ __( 'movie' ) ], + transforms, + supports: { + align: true, + }, + edit, + save, +}; diff --git a/gb-src/packages/block-library/src/video/save.js b/gb-src/packages/block-library/src/video/save.js new file mode 100644 index 0000000000000..f2b621bcadb2d --- /dev/null +++ b/gb-src/packages/block-library/src/video/save.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { autoplay, caption, controls, loop, muted, poster, preload, src, playsInline } = attributes; + return ( + <figure> + { src && ( + <video + autoPlay={ autoplay } + controls={ controls } + loop={ loop } + muted={ muted } + poster={ poster } + preload={ preload !== 'metadata' ? preload : undefined } + src={ src } + playsInline={ playsInline } + /> + ) } + { ! RichText.isEmpty( caption ) && ( + <RichText.Content tagName="figcaption" value={ caption } /> + ) } + </figure> + ); +} diff --git a/gb-src/packages/block-library/src/video/style.native.scss b/gb-src/packages/block-library/src/video/style.native.scss new file mode 100644 index 0000000000000..dd4d70ae0a475 --- /dev/null +++ b/gb-src/packages/block-library/src/video/style.native.scss @@ -0,0 +1,66 @@ +// @format + +.video { + width: 100%; +} + +.videoContainer { + flex: 1; + background-color: #000; +} + +.placeholder { + flex: 1; + justify-content: center; + align-items: center; +} + +.placeholderIcon { + justify-content: center; + align-items: center; + fill: $gray-dark; + background-color: $gray-lighten-30; +} + +.uploadFailedText { + color: $gray-dark; + font-size: 14; + margin-top: 5; +} + +.modalIcon { + width: 40px; + height: 40px; + justify-content: center; + align-items: center; +} + +.modalIconRetry { + width: 80px; + height: 80px; + justify-content: center; + align-items: center; +} + +.container { + flex: 1; +} + +.containerFocused { + flex: 1; + border-color: $blue-medium; + border-width: 2px; + border-style: solid; +} + +.icon { + fill: $gray-dark; + width: 100%; + height: 100%; +} + +.iconUploading { + fill: $gray-lighten-20; + width: 100%; + height: 100%; +} diff --git a/gb-src/packages/block-library/src/video/style.scss b/gb-src/packages/block-library/src/video/style.scss new file mode 100644 index 0000000000000..de4e9499dd67c --- /dev/null +++ b/gb-src/packages/block-library/src/video/style.scss @@ -0,0 +1,26 @@ +.wp-block-video { + // Remove the left and right margin the figure is born with. + margin-left: 0; + margin-right: 0; + + video { + max-width: 100%; + } + + @supports (position: sticky) { + [poster] { + object-fit: cover; + } + } + + &.aligncenter { + text-align: center; + } + + // Supply caption styles to videos, even if the theme hasn't opted in. + // Reason being: the new markup, <figcaptions>, are not likely to be styled in the majority of existing themes, + // so we supply the styles so as to not appear broken or unstyled in those themes. + figcaption { + @include caption-style(); + } +} diff --git a/gb-src/packages/block-library/src/video/theme.scss b/gb-src/packages/block-library/src/video/theme.scss new file mode 100644 index 0000000000000..174535bdf0916 --- /dev/null +++ b/gb-src/packages/block-library/src/video/theme.scss @@ -0,0 +1,3 @@ +.wp-block-video figcaption { + @include caption-style-theme(); +} diff --git a/gb-src/packages/block-library/src/video/tranforms.js b/gb-src/packages/block-library/src/video/tranforms.js new file mode 100644 index 0000000000000..ca070a37855a7 --- /dev/null +++ b/gb-src/packages/block-library/src/video/tranforms.js @@ -0,0 +1,64 @@ +/** + * WordPress dependencies + */ +import { createBlobURL } from '@wordpress/blob'; +import { createBlock } from '@wordpress/blocks'; + +const transforms = { + from: [ + { + type: 'files', + isMatch( files ) { + return files.length === 1 && files[ 0 ].type.indexOf( 'video/' ) === 0; + }, + transform( files ) { + const file = files[ 0 ]; + // We don't need to upload the media directly here + // It's already done as part of the `componentDidMount` + // in the video block + const block = createBlock( 'core/video', { + src: createBlobURL( file ), + } ); + return block; + }, + }, + { + type: 'shortcode', + tag: 'video', + attributes: { + src: { + type: 'string', + shortcode: ( { named: { src, mp4, m4v, webm, ogv, flv } } ) => { + return src || mp4 || m4v || webm || ogv || flv; + }, + }, + poster: { + type: 'string', + shortcode: ( { named: { poster } } ) => { + return poster; + }, + }, + loop: { + type: 'string', + shortcode: ( { named: { loop } } ) => { + return loop; + }, + }, + autoplay: { + type: 'string', + shortcode: ( { named: { autoplay } } ) => { + return autoplay; + }, + }, + preload: { + type: 'string', + shortcode: ( { named: { preload } } ) => { + return preload; + }, + }, + }, + }, + ], +}; + +export default transforms; diff --git a/gb-src/packages/block-library/src/video/video-player.native.js b/gb-src/packages/block-library/src/video/video-player.native.js new file mode 100644 index 0000000000000..31c2cdb7b64ba --- /dev/null +++ b/gb-src/packages/block-library/src/video/video-player.native.js @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * External dependencies + */ +import { View, TouchableOpacity, Platform, Linking, Alert } from 'react-native'; +import { default as VideoPlayer } from 'react-native-video'; + +/** + * Internal dependencies + */ +import styles from './video-player.scss'; +import PlayIcon from './gridicon-play'; + +class Video extends Component { + constructor() { + super( ...arguments ); + this.isIOS = Platform.OS === 'ios'; + this.state = { + isFullScreen: false, + videoContainerHeight: 0, + }; + this.onPressPlay = this.onPressPlay.bind( this ); + this.onVideoLayout = this.onVideoLayout.bind( this ); + } + + onVideoLayout( event ) { + const { height } = event.nativeEvent.layout; + if ( height !== this.state.videoContainerHeight ) { + this.setState( { videoContainerHeight: height } ); + } + } + + onPressPlay() { + if ( this.isIOS ) { + if ( this.player ) { + this.player.presentFullscreenPlayer(); + } + } else { + const { source } = this.props; + if ( source && source.uri ) { + this.openURL( source.uri ); + } + } + } + + // Tries opening the URL outside of the app + openURL( url ) { + Linking.canOpenURL( url ).then( ( supported ) => { + if ( ! supported ) { + Alert.alert( __( 'Problem opening the video' ), __( 'No application can handle this request. Please install a Web browser.' ) ); + window.console.warn( 'No application found that can open the video with URL: ' + url ); + } else { + return Linking.openURL( url ); + } + } ).catch( ( err ) => { + Alert.alert( __( 'Problem opening the video' ), __( 'An unknown error occurred. Please try again.' ) ); + window.console.error( 'An error occurred while opening the video URL: ' + url, err ); + } ); + } + + render() { + const { isSelected, style } = this.props; + const { isFullScreen, videoContainerHeight } = this.state; + const showPlayButton = videoContainerHeight > 0; + + return ( + <View style={ styles.videoContainer }> + <VideoPlayer + { ...this.props } + ref={ ( ref ) => { + this.player = ref; + } } + // Using built-in player controls is messing up the layout on iOS. + // So we are setting controls=false and adding a play button that + // will trigger presentFullscreenPlayer() + controls={ false } + ignoreSilentSwitch={ 'ignore' } + paused={ ! isFullScreen } + onLayout={ this.onVideoLayout } + onFullscreenPlayerWillPresent={ () => { + this.setState( { isFullScreen: true } ); + } } + onFullscreenPlayerDidDismiss={ () => { + this.setState( { isFullScreen: false } ); + } } + /> + { showPlayButton && + // If we add the play icon as a subview to VideoPlayer then react-native-video decides to show control buttons + // even if we set controls={ false }, so we are adding our play button as a sibling overlay view. + <TouchableOpacity disabled={ ! isSelected } onPress={ this.onPressPlay } style={ [ style, styles.overlayContainer ] }> + <View style={ styles.blackOverlay } /> + <Icon icon={ PlayIcon } style={ styles.playIcon } size={ styles.playIcon.size } /> + </TouchableOpacity> + } + </View> + ); + } +} + +export default Video; diff --git a/gb-src/packages/block-library/src/video/video-player.native.scss b/gb-src/packages/block-library/src/video/video-player.native.scss new file mode 100644 index 0000000000000..4dd74ba25e880 --- /dev/null +++ b/gb-src/packages/block-library/src/video/video-player.native.scss @@ -0,0 +1,29 @@ +// @format + +$play-icon-size: 50; + +.videoContainer { + flex: 1; + justify-content: center; + align-items: center; +} + +.overlayContainer { + justify-content: center; + align-items: center; + align-self: center; + position: absolute; +} + +.blackOverlay { + width: 100%; + height: 100%; + background-color: $black; + opacity: 0.4; +} + +.playIcon { + position: absolute; + opacity: 0.7; + size: 64; +} diff --git a/gb-src/packages/block-serialization-default-parser/.npmrc b/gb-src/packages/block-serialization-default-parser/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/block-serialization-default-parser/CHANGELOG.md b/gb-src/packages/block-serialization-default-parser/CHANGELOG.md new file mode 100644 index 0000000000000..1fd5900744ecc --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/CHANGELOG.md @@ -0,0 +1,27 @@ +## 2.0.3 (2019-01-03) + +## 2.0.2 (2018-12-12) + +## 2.0.1 (2018-11-30) + +## 2.0.0 (2018-11-12) + +### Breaking Changes + +- Inner blocks are now cast as arrays instead of objects. +- JS and PHP parsers now behave consistently when parsing empty attributes. + +## 1.1.1 (2018-11-09) + +## 1.1.0 (2018-11-09) + +- Add new list of HTML fragments to parse output. +- Optimize JSON-attribute parsing. + +## 1.0.2 (2018-11-03) + +## 1.0.1 (2018-10-11) + +## 1.0.0 (2018-09-30) + +- Initial release. diff --git a/gb-src/packages/block-serialization-default-parser/README.md b/gb-src/packages/block-serialization-default-parser/README.md new file mode 100644 index 0000000000000..e7cf1987b45d5 --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/README.md @@ -0,0 +1,147 @@ +# Block Serialization Default Parser + +This library contains the default block serialization parser implementations for WordPress documents. It provides native PHP and JavaScript parsers that implement the [specification](/docs/contributors/grammar.md) from [`@wordpress/block-serialization-spec-parser`](/packages/block-serialization-spec-parser/README.md) and which normally operates on the document stored in `post_content`. + +## Installation + +Install the module + +```bash +npm install @wordpress/block-serialization-default-parser --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="parse" href="#parse">#</a> **parse** + +Parser function, that converts input HTML into a block based structure. + +_Usage_ + +Input post: + +```html +<!-- wp:columns {"columns":3} --> +<div class="wp-block-columns has-3-columns"><!-- wp:column --> +<div class="wp-block-column"><!-- wp:paragraph --> +<p>Left</p> +<!-- /wp:paragraph --></div> +<!-- /wp:column --> + +<!-- wp:column --> +<div class="wp-block-column"><!-- wp:paragraph --> +<p><strong>Middle</strong></p> +<!-- /wp:paragraph --></div> +<!-- /wp:column --> + +<!-- wp:column --> +<div class="wp-block-column"></div> +<!-- /wp:column --></div> +<!-- /wp:columns --> +``` + +Parsing code: + +```js +import { parse } from '@wordpress/block-serialization-default-parser'; + +parse( post ) === [ + { + blockName: "core/columns", + attrs: { + columns: 3 + }, + innerBlocks: [ + { + blockName: "core/column", + attrs: null, + innerBlocks: [ + { + blockName: "core/paragraph", + attrs: null, + innerBlocks: [], + innerHTML: "\n<p>Left</p>\n" + } + ], + innerHTML: '\n<div class="wp-block-column"></div>\n' + }, + { + blockName: "core/column", + attrs: null, + innerBlocks: [ + { + blockName: "core/paragraph", + attrs: null, + innerBlocks: [], + innerHTML: "\n<p><strong>Middle</strong></p>\n" + } + ], + innerHTML: '\n<div class="wp-block-column"></div>\n' + }, + { + blockName: "core/column", + attrs: null, + innerBlocks: [], + innerHTML: '\n<div class="wp-block-column"></div>\n' + } + ], + innerHTML: '\n<div class="wp-block-columns has-3-columns">\n\n\n\n</div>\n' + } +]; +``` + +_Parameters_ + +- _doc_ `string`: The HTML document to parse. + +_Returns_ + +- `Array`: A block-based representation of the input HTML. + + +<!-- END TOKEN(Autogenerated API docs) --> + +## Theory + +### What is different about this one from the spec-parser? + +This is a recursive-descent parser that scans linearly once through the input document. Instead of directly recursing it utilizes a trampoline mechanism to prevent stack overflow. It minimizes data copying and passing through the use of globals for tracking state through the parse. Between every token (a block comment delimiter) we can instrument the parser and intervene should we want to; for example we might put a hard limit on how long we can be parsing a document or provide additional debugging diagnostics for a document. + +The spec parser is defined via a _Parsing Expression Grammar_ (PEG) which answers many questions inherently that we must answer explicitly in this parser. The goal for this implementation is to match the characteristics of the PEG so that it can be directly swapped out and so that the only changes are better runtime performance and memory usage. + +### How does it work? + +Every serialized Gutenberg document is nominally an HTML document which, in addition to normal HTML, may also contain specially designed HTML comments -- the block comment delimiters -- which separate and isolate the blocks serialized in the document. + +This parser attempts to create a state-machine around the transitions triggered from those delimiters -- the "tokens" of the grammar. Every time we find one we should only be doing either of: + +- enter a new block; +- exit out of a block. + +Those actions have different effects depending on the context; for instance, when we exit a block we either need to add it to the output block list _or_ we need to append it as the next `innerBlock` on the parent block below it in the block stack (the place where we track open blocks). The details are documented below. + +The biggest challenge in this parser is making the right accounting of indices required to construct the `innerHTML` values for each block at every level of nesting depth. We take a simple approach: + +- Start each newly opened block with an empty `innerHTML`. +- Whenever we push a first block into the `innerBlocks` list, add the content from where the content of the parent block started to where this inner block starts. +- Whenever we push another block into the `innerBlocks` list, add the content from where the previous inner block ended to where this inner block starts. +- When we close out an open block, add the content from where the last inner block ended to where the closing block delimiter starts. +- If there are no inner blocks then we take the entire content between the opening and closing block comment delimiters as the `innerHTML`. + +### I meant, how does it perform? + +This parser operates much faster than the generated parser from the specification. Because we know more about the parsing than the PEG does we can take advantage of several tricks to improve our speed and memory usage: + +- We only have one or two distinct tokens, depending on how you look at it, and they are all readily matched via a regular expression. Instead of parsing on a character-per-character basis we can allow the PCRE RegExp engine to skip over large swaths of the document for us in order to find those tokens. +- Since `preg_match()` takes an `offset` parameter we can crawl through the input without passing copies of the input text on every step. We can track our position in the string and only pass a number instead. +- Not copying all those strings means that we'll also skip many memory allocations. + +Further, tokenizing with a RegExp brings an additional advantage. The parser generated by the PEG provides predictable performance characteristics in exchange for control over tokenization rules -- it doesn't allow us to define RegExp patterns in the rules so as to guard against _e.g._ cataclysmic backtracking that would break the PEG guarantees. + +However, since our "token language" of the block comment delimiters is _regular_ and _can_ be trivially matched with RegExp patterns, we can do that here and then something magical happens: we jump out of PHP or JavaScript and into a highly-optimized RegExp engine written in C or C++ on the host system. We thereby leave the virtual machine and its overhead. + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/block-serialization-default-parser/package.json b/gb-src/packages/block-serialization-default-parser/package.json new file mode 100644 index 0000000000000..8f2695921aa11 --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/block-serialization-default-parser", + "version": "3.4.1", + "description": "Block serialization specification parser for WordPress posts.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "block", + "parser" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-serialization-default-parser/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-serialization-default-parser" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/block-serialization-default-parser/parser.php b/gb-src/packages/block-serialization-default-parser/parser.php new file mode 100644 index 0000000000000..eb4c21a973c0f --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/parser.php @@ -0,0 +1,555 @@ +<?php +/** + * Block Serialization Parser + * + * @package WordPress + */ + +/** + * Class WP_Block_Parser_Block + * + * Holds the block structure in memory + * + * @since 3.8.0 + */ +class WP_Block_Parser_Block { + /** + * Name of block + * + * @example "core/paragraph" + * + * @since 3.8.0 + * @var string + */ + public $blockName; + + /** + * Optional set of attributes from block comment delimiters + * + * @example null + * @example array( 'columns' => 3 ) + * + * @since 3.8.0 + * @var array|null + */ + public $attrs; + + /** + * List of inner blocks (of this same class) + * + * @since 3.8.0 + * @var WP_Block_Parser_Block[] + */ + public $innerBlocks; + + /** + * Resultant HTML from inside block comment delimiters + * after removing inner blocks + * + * @example "...Just <!-- wp:test /--> testing..." -> "Just testing..." + * + * @since 3.8.0 + * @var string + */ + public $innerHTML; + + /** + * List of string fragments and null markers where inner blocks were found + * + * @example array( + * 'innerHTML' => 'BeforeInnerAfter', + * 'innerBlocks' => array( block, block ), + * 'innerContent' => array( 'Before', null, 'Inner', null, 'After' ), + * ) + * + * @since 4.2.0 + * @var array + */ + public $innerContent; + + /** + * Constructor. + * + * Will populate object properties from the provided arguments. + * + * @since 3.8.0 + * + * @param string $name Name of block. + * @param array $attrs Optional set of attributes from block comment delimiters. + * @param array $innerBlocks List of inner blocks (of this same class). + * @param string $innerHTML Resultant HTML from inside block comment delimiters after removing inner blocks. + * @param array $innerContent List of string fragments and null markers where inner blocks were found. + */ + function __construct( $name, $attrs, $innerBlocks, $innerHTML, $innerContent ) { + $this->blockName = $name; + $this->attrs = $attrs; + $this->innerBlocks = $innerBlocks; + $this->innerHTML = $innerHTML; + $this->innerContent = $innerContent; + } +} + +/** + * Class WP_Block_Parser_Frame + * + * Holds partial blocks in memory while parsing + * + * @internal + * @since 3.8.0 + */ +class WP_Block_Parser_Frame { + /** + * Full or partial block + * + * @since 3.8.0 + * @var WP_Block_Parser_Block + */ + public $block; + + /** + * Byte offset into document for start of parse token + * + * @since 3.8.0 + * @var int + */ + public $token_start; + + /** + * Byte length of entire parse token string + * + * @since 3.8.0 + * @var int + */ + public $token_length; + + /** + * Byte offset into document for after parse token ends + * (used during reconstruction of stack into parse production) + * + * @since 3.8.0 + * @var int + */ + public $prev_offset; + + /** + * Byte offset into document where leading HTML before token starts + * + * @since 3.8.0 + * @var int + */ + public $leading_html_start; + + /** + * Constructor + * + * Will populate object properties from the provided arguments. + * + * @since 3.8.0 + * + * @param WP_Block_Parser_Block $block Full or partial block. + * @param int $token_start Byte offset into document for start of parse token. + * @param int $token_length Byte length of entire parse token string. + * @param int $prev_offset Byte offset into document for after parse token ends. + * @param int $leading_html_start Byte offset into document where leading HTML before token starts. + */ + function __construct( $block, $token_start, $token_length, $prev_offset = null, $leading_html_start = null ) { + $this->block = $block; + $this->token_start = $token_start; + $this->token_length = $token_length; + $this->prev_offset = isset( $prev_offset ) ? $prev_offset : $token_start + $token_length; + $this->leading_html_start = $leading_html_start; + } +} + +/** + * Class WP_Block_Parser + * + * Parses a document and constructs a list of parsed block objects + * + * @since 3.8.0 + * @since 4.0.0 returns arrays not objects, all attributes are arrays + */ +class WP_Block_Parser { + /** + * Input document being parsed + * + * @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->" + * + * @since 3.8.0 + * @var string + */ + public $document; + + /** + * Tracks parsing progress through document + * + * @since 3.8.0 + * @var int + */ + public $offset; + + /** + * List of parsed blocks + * + * @since 3.8.0 + * @var WP_Block_Parser_Block[] + */ + public $output; + + /** + * Stack of partially-parsed structures in memory during parse + * + * @since 3.8.0 + * @var WP_Block_Parser_Frame[] + */ + public $stack; + + /** + * Empty associative array, here due to PHP quirks + * + * @since 4.4.0 + * @var array empty associative array + */ + public $empty_attrs; + + /** + * Parses a document and returns a list of block structures + * + * When encountering an invalid parse will return a best-effort + * parse. In contrast to the specification parser this does not + * return an error on invalid inputs. + * + * @since 3.8.0 + * + * @param string $document Input document being parsed. + * @return WP_Block_Parser_Block[] + */ + function parse( $document ) { + $this->document = $document; + $this->offset = 0; + $this->output = array(); + $this->stack = array(); + $this->empty_attrs = json_decode( '{}', true ); + + do { + // twiddle our thumbs. + } while ( $this->proceed() ); + + return $this->output; + } + + /** + * Processes the next token from the input document + * and returns whether to proceed eating more tokens + * + * This is the "next step" function that essentially + * takes a token as its input and decides what to do + * with that token before descending deeper into a + * nested block tree or continuing along the document + * or breaking out of a level of nesting. + * + * @internal + * @since 3.8.0 + * @return bool + */ + function proceed() { + $next_token = $this->next_token(); + list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token; + $stack_depth = count( $this->stack ); + + // we may have some HTML soup before the next block. + $leading_html_start = $start_offset > $this->offset ? $this->offset : null; + + switch ( $token_type ) { + case 'no-more-tokens': + // if not in a block then flush output. + if ( 0 === $stack_depth ) { + $this->add_freeform(); + return false; + } + + /* + * Otherwise we have a problem + * This is an error + * + * we have options + * - treat it all as freeform text + * - assume an implicit closer (easiest when not nesting) + */ + + // for the easy case we'll assume an implicit closer. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack(); + return false; + } + + /* + * for the nested case where it's more difficult we'll + * have to assume that multiple closers are missing + * and so we'll collapse the whole stack piecewise + */ + while ( 0 < count( $this->stack ) ) { + $this->add_block_from_stack(); + } + return false; + + case 'void-block': + /* + * easy case is if we stumbled upon a void block + * in the top-level of the document + */ + if ( 0 === $stack_depth ) { + if ( isset( $leading_html_start ) ) { + $this->output[] = (array) self::freeform( + substr( + $this->document, + $leading_html_start, + $start_offset - $leading_html_start + ) + ); + } + + $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ); + $this->offset = $start_offset + $token_length; + return true; + } + + // otherwise we found an inner block. + $this->add_inner_block( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-opener': + // track all newly-opened blocks on the stack. + array_push( + $this->stack, + new WP_Block_Parser_Frame( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length, + $start_offset + $token_length, + $leading_html_start + ) + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-closer': + /* + * if we're missing an opener we're in trouble + * This is an error + */ + if ( 0 === $stack_depth ) { + /* + * we have options + * - assume an implicit opener + * - assume _this_ is the opener + * - give up and close out the document + */ + $this->add_freeform(); + return false; + } + + // if we're not nesting then this is easy - close the block. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack( $start_offset ); + $this->offset = $start_offset + $token_length; + return true; + } + + /* + * otherwise we're nested and we have to close out the current + * block and add it as a new innerBlock to the parent + */ + $stack_top = array_pop( $this->stack ); + $html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset ); + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + $stack_top->prev_offset = $start_offset + $token_length; + + $this->add_inner_block( + $stack_top->block, + $stack_top->token_start, + $stack_top->token_length, + $start_offset + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + default: + // This is an error. + $this->add_freeform(); + return false; + } + } + + /** + * Scans the document from where we last left off + * and finds the next valid token to parse if it exists + * + * Returns the type of the find: kind of find, block information, attributes + * + * @internal + * @since 3.8.0 + * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments + * @return array + */ + function next_token() { + $matches = null; + + /* + * aye the magic + * we're using a single RegExp to tokenize the block comment delimiters + * we're also using a trick here because the only difference between a + * block opener and a block closer is the leading `/` before `wp:` (and + * a closer has no attributes). we can trap them both and process the + * match back in PHP to see which one it was. + */ + $has_match = preg_match( + '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s', + $this->document, + $matches, + PREG_OFFSET_CAPTURE, + $this->offset + ); + + // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE. + if ( false === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + // we have no more tokens. + if ( 0 === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + list( $match, $started_at ) = $matches[0]; + + $length = strlen( $match ); + $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1]; + $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1]; + $namespace = $matches['namespace']; + $namespace = ( isset( $namespace ) && -1 !== $namespace[1] ) ? $namespace[0] : 'core/'; + $name = $namespace . $matches['name'][0]; + $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1]; + + /* + * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays + * are associative arrays. If we use `array()` we get a JSON `[]` + */ + $attrs = $has_attrs + ? json_decode( $matches['attrs'][0], /* as-associative */ true ) + : $this->empty_attrs; + + /* + * This state isn't allowed + * This is an error + */ + if ( $is_closer && ( $is_void || $has_attrs ) ) { + // we can ignore them since they don't hurt anything. + } + + if ( $is_void ) { + return array( 'void-block', $name, $attrs, $started_at, $length ); + } + + if ( $is_closer ) { + return array( 'block-closer', $name, null, $started_at, $length ); + } + + return array( 'block-opener', $name, $attrs, $started_at, $length ); + } + + /** + * Returns a new block object for freeform HTML + * + * @internal + * @since 3.9.0 + * + * @param string $innerHTML HTML content of block. + * @return WP_Block_Parser_Block freeform block object. + */ + function freeform( $innerHTML ) { + return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $innerHTML, array( $innerHTML ) ); + } + + /** + * Pushes a length of text from the input document + * to the output list as a freeform block. + * + * @internal + * @since 3.8.0 + * @param null $length how many bytes of document text to output. + */ + function add_freeform( $length = null ) { + $length = $length ? $length : strlen( $this->document ) - $this->offset; + + if ( 0 === $length ) { + return; + } + + $this->output[] = (array) self::freeform( substr( $this->document, $this->offset, $length ) ); + } + + /** + * Given a block structure from memory pushes + * a new block to the output list. + * + * @internal + * @since 3.8.0 + * @param WP_Block_Parser_Block $block The block to add to the output. + * @param int $token_start Byte offset into the document where the first token for the block starts. + * @param int $token_length Byte length of entire block from start of opening token to end of closing token. + * @param int|null $last_offset Last byte offset into document if continuing form earlier output. + */ + function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { + $parent = $this->stack[ count( $this->stack ) - 1 ]; + $parent->block->innerBlocks[] = (array) $block; + $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); + + if ( ! empty( $html ) ) { + $parent->block->innerHTML .= $html; + $parent->block->innerContent[] = $html; + } + + $parent->block->innerContent[] = null; + $parent->prev_offset = $last_offset ? $last_offset : $token_start + $token_length; + } + + /** + * Pushes the top block from the parsing stack to the output list. + * + * @internal + * @since 3.8.0 + * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML. + */ + function add_block_from_stack( $end_offset = null ) { + $stack_top = array_pop( $this->stack ); + $prev_offset = $stack_top->prev_offset; + + $html = isset( $end_offset ) + ? substr( $this->document, $prev_offset, $end_offset - $prev_offset ) + : substr( $this->document, $prev_offset ); + + if ( ! empty( $html ) ) { + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + } + + if ( isset( $stack_top->leading_html_start ) ) { + $this->output[] = (array) self::freeform( + substr( + $this->document, + $stack_top->leading_html_start, + $stack_top->token_start - $stack_top->leading_html_start + ) + ); + } + + $this->output[] = (array) $stack_top->block; + } +} diff --git a/gb-src/packages/block-serialization-default-parser/src/index.js b/gb-src/packages/block-serialization-default-parser/src/index.js new file mode 100644 index 0000000000000..d92fcfc5fc18b --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/src/index.js @@ -0,0 +1,378 @@ +let document; +let offset; +let output; +let stack; + +/** + * Matches block comment delimiters + * + * While most of this pattern is straightforward the attribute parsing + * incorporates a tricks to make sure we don't choke on specific input + * + * - since JavaScript has no possessive quantifier or atomic grouping + * we are emulating it with a trick + * + * we want a possessive quantifier or atomic group to prevent backtracking + * on the `}`s should we fail to match the remainder of the pattern + * + * we can emulate this with a positive lookahead and back reference + * (a++)*c === ((?=(a+))\1)*c + * + * let's examine an example: + * - /(a+)*c/.test('aaaaaaaaaaaaad') fails after over 49,000 steps + * - /(a++)*c/.test('aaaaaaaaaaaaad') fails after 85 steps + * - /(?>a+)*c/.test('aaaaaaaaaaaaad') fails after 126 steps + * + * this is because the possessive `++` and the atomic group `(?>)` + * tell the engine that all those `a`s belong together as a single group + * and so it won't split it up when stepping backwards to try and match + * + * if we use /((?=(a+))\1)*c/ then we get the same behavior as the atomic group + * or possessive and prevent the backtracking because the `a+` is matched but + * not captured. thus, we find the long string of `a`s and remember it, then + * reference it as a whole unit inside our pattern + * + * @see http://instanceof.me/post/52245507631/regex-emulate-atomic-grouping-with-lookahead + * @see http://blog.stevenlevithan.com/archives/mimic-atomic-groups + * @see https://javascript.info/regexp-infinite-backtracking-problem + * + * once browsers reliably support atomic grouping or possessive + * quantifiers natively we should remove this trick and simplify + * + * @type RegExp + * + * @since 3.8.0 + * @since 4.6.1 added optimization to prevent backtracking on attribute parsing + */ +const tokenizer = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:(?=([^}]+|}+(?=})|(?!}\s+\/?-->)[^])*)\5|[^]*?)}\s+)?(\/)?-->/g; + +function Block( blockName, attrs, innerBlocks, innerHTML, innerContent ) { + return { + blockName, + attrs, + innerBlocks, + innerHTML, + innerContent, + }; +} + +function Freeform( innerHTML ) { + return Block( null, {}, [], innerHTML, [ innerHTML ] ); +} + +function Frame( block, tokenStart, tokenLength, prevOffset, leadingHtmlStart ) { + return { + block, + tokenStart, + tokenLength, + prevOffset: prevOffset || tokenStart + tokenLength, + leadingHtmlStart, + }; +} + +/** + * Parser function, that converts input HTML into a block based structure. + * + * @param {string} doc The HTML document to parse. + * + * @example + * Input post: + * ```html + * <!-- wp:columns {"columns":3} --> + * <div class="wp-block-columns has-3-columns"><!-- wp:column --> + * <div class="wp-block-column"><!-- wp:paragraph --> + * <p>Left</p> + * <!-- /wp:paragraph --></div> + * <!-- /wp:column --> + * + * <!-- wp:column --> + * <div class="wp-block-column"><!-- wp:paragraph --> + * <p><strong>Middle</strong></p> + * <!-- /wp:paragraph --></div> + * <!-- /wp:column --> + * + * <!-- wp:column --> + * <div class="wp-block-column"></div> + * <!-- /wp:column --></div> + * <!-- /wp:columns --> + * ``` + * + * Parsing code: + * ```js + * import { parse } from '@wordpress/block-serialization-default-parser'; + * + * parse( post ) === [ + * { + * blockName: "core/columns", + * attrs: { + * columns: 3 + * }, + * innerBlocks: [ + * { + * blockName: "core/column", + * attrs: null, + * innerBlocks: [ + * { + * blockName: "core/paragraph", + * attrs: null, + * innerBlocks: [], + * innerHTML: "\n<p>Left</p>\n" + * } + * ], + * innerHTML: '\n<div class="wp-block-column"></div>\n' + * }, + * { + * blockName: "core/column", + * attrs: null, + * innerBlocks: [ + * { + * blockName: "core/paragraph", + * attrs: null, + * innerBlocks: [], + * innerHTML: "\n<p><strong>Middle</strong></p>\n" + * } + * ], + * innerHTML: '\n<div class="wp-block-column"></div>\n' + * }, + * { + * blockName: "core/column", + * attrs: null, + * innerBlocks: [], + * innerHTML: '\n<div class="wp-block-column"></div>\n' + * } + * ], + * innerHTML: '\n<div class="wp-block-columns has-3-columns">\n\n\n\n</div>\n' + * } + * ]; + * ``` + * @return {Array} A block-based representation of the input HTML. + */ +export const parse = ( doc ) => { + document = doc; + offset = 0; + output = []; + stack = []; + tokenizer.lastIndex = 0; + + do { + // twiddle our thumbs + } while ( proceed() ); + + return output; +}; + +function proceed() { + const next = nextToken(); + const [ tokenType, blockName, attrs, startOffset, tokenLength ] = next; + const stackDepth = stack.length; + + // we may have some HTML soup before the next block + const leadingHtmlStart = ( startOffset > offset ) ? offset : null; + + switch ( tokenType ) { + case 'no-more-tokens': + // if not in a block then flush output + if ( 0 === stackDepth ) { + addFreeform(); + return false; + } + + // Otherwise we have a problem + // This is an error + // we have options + // - treat it all as freeform text + // - assume an implicit closer (easiest when not nesting) + + // for the easy case we'll assume an implicit closer + if ( 1 === stackDepth ) { + addBlockFromStack(); + return false; + } + + // for the nested case where it's more difficult we'll + // have to assume that multiple closers are missing + // and so we'll collapse the whole stack piecewise + while ( 0 < stack.length ) { + addBlockFromStack(); + } + return false; + + case 'void-block': + // easy case is if we stumbled upon a void block + // in the top-level of the document + if ( 0 === stackDepth ) { + if ( null !== leadingHtmlStart ) { + output.push( Freeform( document.substr( leadingHtmlStart, startOffset - leadingHtmlStart ) ) ); + } + output.push( Block( blockName, attrs, [], '', [] ) ); + offset = startOffset + tokenLength; + return true; + } + + // otherwise we found an inner block + addInnerBlock( + Block( blockName, attrs, [], '', [] ), + startOffset, + tokenLength, + ); + offset = startOffset + tokenLength; + return true; + + case 'block-opener': + // track all newly-opened blocks on the stack + stack.push( + Frame( + Block( blockName, attrs, [], '', [] ), + startOffset, + tokenLength, + startOffset + tokenLength, + leadingHtmlStart, + ), + ); + offset = startOffset + tokenLength; + return true; + + case 'block-closer': + // if we're missing an opener we're in trouble + // This is an error + if ( 0 === stackDepth ) { + // we have options + // - assume an implicit opener + // - assume _this_ is the opener + // - give up and close out the document + addFreeform(); + return false; + } + + // if we're not nesting then this is easy - close the block + if ( 1 === stackDepth ) { + addBlockFromStack( startOffset ); + offset = startOffset + tokenLength; + return true; + } + + // otherwise we're nested and we have to close out the current + // block and add it as a innerBlock to the parent + const stackTop = stack.pop(); + const html = document.substr( stackTop.prevOffset, startOffset - stackTop.prevOffset ); + stackTop.block.innerHTML += html; + stackTop.block.innerContent.push( html ); + stackTop.prevOffset = startOffset + tokenLength; + + addInnerBlock( + stackTop.block, + stackTop.tokenStart, + stackTop.tokenLength, + startOffset + tokenLength, + ); + offset = startOffset + tokenLength; + return true; + + default: + // This is an error + addFreeform(); + return false; + } +} + +/** + * Parse JSON if valid, otherwise return null + * + * Note that JSON coming from the block comment + * delimiters is constrained to be an object + * and cannot be things like `true` or `null` + * + * @param {string} input JSON input string to parse + * @return {Object|null} parsed JSON if valid + */ +function parseJSON( input ) { + try { + return JSON.parse( input ); + } catch ( e ) { + return null; + } +} + +function nextToken() { + // aye the magic + // we're using a single RegExp to tokenize the block comment delimiters + // we're also using a trick here because the only difference between a + // block opener and a block closer is the leading `/` before `wp:` (and + // a closer has no attributes). we can trap them both and process the + // match back in Javascript to see which one it was. + const matches = tokenizer.exec( document ); + + // we have no more tokens + if ( null === matches ) { + return [ 'no-more-tokens' ]; + } + + const startedAt = matches.index; + const [ match, closerMatch, namespaceMatch, nameMatch, attrsMatch, /* internal/unused */, voidMatch ] = matches; + + const length = match.length; + const isCloser = !! closerMatch; + const isVoid = !! voidMatch; + const namespace = namespaceMatch || 'core/'; + const name = namespace + nameMatch; + const hasAttrs = !! attrsMatch; + const attrs = hasAttrs ? parseJSON( attrsMatch ) : {}; + + // This state isn't allowed + // This is an error + if ( isCloser && ( isVoid || hasAttrs ) ) { + // we can ignore them since they don't hurt anything + // we may warn against this at some point or reject it + } + + if ( isVoid ) { + return [ 'void-block', name, attrs, startedAt, length ]; + } + + if ( isCloser ) { + return [ 'block-closer', name, null, startedAt, length ]; + } + + return [ 'block-opener', name, attrs, startedAt, length ]; +} + +function addFreeform( rawLength ) { + const length = rawLength ? rawLength : document.length - offset; + + if ( 0 === length ) { + return; + } + + output.push( Freeform( document.substr( offset, length ) ) ); +} + +function addInnerBlock( block, tokenStart, tokenLength, lastOffset ) { + const parent = stack[ stack.length - 1 ]; + parent.block.innerBlocks.push( block ); + const html = document.substr( parent.prevOffset, tokenStart - parent.prevOffset ); + + if ( html ) { + parent.block.innerHTML += html; + parent.block.innerContent.push( html ); + } + + parent.block.innerContent.push( null ); + parent.prevOffset = lastOffset ? lastOffset : tokenStart + tokenLength; +} + +function addBlockFromStack( endOffset ) { + const { block, leadingHtmlStart, prevOffset, tokenStart } = stack.pop(); + + const html = endOffset ? document.substr( prevOffset, endOffset - prevOffset ) : document.substr( prevOffset ); + + if ( html ) { + block.innerHTML += html; + block.innerContent.push( html ); + } + + if ( null !== leadingHtmlStart ) { + output.push( Freeform( document.substr( leadingHtmlStart, tokenStart - leadingHtmlStart ) ) ); + } + + output.push( block ); +} diff --git a/gb-src/packages/block-serialization-default-parser/test/index.js b/gb-src/packages/block-serialization-default-parser/test/index.js new file mode 100644 index 0000000000000..33c494cf51ab5 --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/test/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import path from 'path'; + +/** + * WordPress dependencies + */ +// eslint-disable-next-line no-restricted-syntax +import { jsTester, phpTester } from '@wordpress/block-serialization-spec-parser/shared-tests'; + +/** + * Internal dependencies + */ +import { parse } from '../src'; + +describe( 'block-serialization-default-parser-js', jsTester( parse ) ); // eslint-disable-line jest/valid-describe + +phpTester( 'block-serialization-default-parser-php', path.join( __dirname, 'test-parser.php' ) ); diff --git a/gb-src/packages/block-serialization-default-parser/test/test-parser.php b/gb-src/packages/block-serialization-default-parser/test/test-parser.php new file mode 100644 index 0000000000000..1391c0edd1684 --- /dev/null +++ b/gb-src/packages/block-serialization-default-parser/test/test-parser.php @@ -0,0 +1,15 @@ +<?php +/** + * PHP Test Helper + * + * Facilitates running PHP parser against same tests as the JS parser implementation. + * + * @package gutenberg + */ + +// Include the default parser. +require_once dirname( __FILE__ ) . '/../parser.php'; + +$parser = new WP_Block_Parser(); + +echo json_encode( $parser->parse( file_get_contents( 'php://stdin' ) ) ); diff --git a/gb-src/packages/block-serialization-spec-parser/.eslintrc.json b/gb-src/packages/block-serialization-spec-parser/.eslintrc.json new file mode 100644 index 0000000000000..ffee0154641f1 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "overrides": [ + { + "files": [ "shared-tests.js" ], + "extends": [ + "plugin:@wordpress/eslint-plugin/test-unit" + ] + } + ] +} diff --git a/gb-src/packages/block-serialization-spec-parser/.npmignore b/gb-src/packages/block-serialization-spec-parser/.npmignore new file mode 100644 index 0000000000000..efd9db21d6ec0 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/.npmignore @@ -0,0 +1,2 @@ +# Intentionally empty, to avoid inheriting from `.gitignore`, since `parser.js` +# and `parser.php` should be included with the npm distributable. diff --git a/gb-src/packages/block-serialization-spec-parser/.npmrc b/gb-src/packages/block-serialization-spec-parser/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/block-serialization-spec-parser/CHANGELOG.md b/gb-src/packages/block-serialization-spec-parser/CHANGELOG.md new file mode 100644 index 0000000000000..59a1acaa802a1 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/CHANGELOG.md @@ -0,0 +1,38 @@ +## 3.0.0 (2019-03-06) + +## Breaking Change + +- A `parser.js` file generated from the PEGJS grammar is now outputted in commonjs format. + +## New Feature + +- A `parser.php` file generated from the PEGJS grammar is now added upon installation. + +## 2.0.2 (2018-12-12) + +## 2.0.1 (2018-11-30) + +## 2.0.0 (2018-11-12) + +### Breaking Change + +- JS and PHP parsers now behave consistently when parsing empty attributes. + +## 1.1.1 (2018-11-09) + +## 1.1.0 (2018-11-09) + +- Add new list of HTML fragments to parse output. +- Optimize JSON-attribute parsing. + +## 1.0.4 (2018-11-03) + +## 1.0.3 (2018-10-11) + +## 1.0.2 (2018-10-01) + +## 1.0.1 (2018-08-01) + +## 1.0.0 (2018-07-20) + +- Initial release. diff --git a/gb-src/packages/block-serialization-spec-parser/README.md b/gb-src/packages/block-serialization-spec-parser/README.md new file mode 100644 index 0000000000000..e4750b42cdfda --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/README.md @@ -0,0 +1,27 @@ +# Block Serialization Spec Parser + +This library contains the grammar file (`grammar.pegjs`) for WordPress posts which is a block serialization [_specification_](/docs/contributors/grammar.md) which is used to generate the actual _parser_ which is also bundled in this package. + +PEG parser generators are available in many languages, though different libraries may require some translation of this grammar into their syntax. For more information see: + +* [PEG.js](https://pegjs.org) +* [Parsing expression grammar](https://en.wikipedia.org/wiki/Parsing_expression_grammar) + +## Installation + +Install the module + +```bash +npm install @wordpress/block-serialization-spec-parser --save +``` + +## Usage + +```js +import { parse } from '@wordpress/block-serialization-spec-parser'; + +parse( '<!-- wp:core/more --><!--more--><!-- /wp:core/more -->' ); +// [{"attrs": null, "blockName": "core/more", "innerBlocks": [], "innerHTML": "<!--more-->"}] +``` + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/block-serialization-spec-parser/bin/create-php-parser.js b/gb-src/packages/block-serialization-spec-parser/bin/create-php-parser.js new file mode 100755 index 0000000000000..c9e48a200fa48 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/bin/create-php-parser.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +const pegjs = require( 'pegjs' ); +const phpegjs = require( 'phpegjs' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const peg = fs.readFileSync( path.join( __dirname, '..', 'grammar.pegjs' ), 'utf8' ); + +const parser = pegjs.generate( + peg, + { + plugins: [ phpegjs ], + phpegjs: { + parserNamespace: null, + parserGlobalNamePrefix: 'Gutenberg_PEG_', + mbstringAllowed: false, + }, + } +); + +fs.writeFileSync( + path.join( __dirname, '..', 'parser.php' ), + parser +); diff --git a/gb-src/packages/block-serialization-spec-parser/grammar.pegjs b/gb-src/packages/block-serialization-spec-parser/grammar.pegjs new file mode 100644 index 0000000000000..19b8dd9e70487 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/grammar.pegjs @@ -0,0 +1,331 @@ +{ + +/* + * + * _____ _ _ + * / ____| | | | | + * | | __ _ _| |_ ___ _ __ | |__ ___ _ __ __ _ + * | | |_ | | | | __/ _ \ '_ \| '_ \ / _ \ '__/ _` | + * | |__| | |_| | || __/ | | | |_) | __/ | | (_| | + * \_____|\__,_|\__\___|_| |_|_.__/ \___|_| \__, | + * __/ | + * GRAMMAR |___/ + * + * + * Welcome to the grammar file for Gutenberg posts! + * + * Please don't be distracted by the functions at the top + * here - they're just helpers for the grammar below. We + * try to keep them as minimal and simple as possible, + * but the parser generator forces us to declare them at + * the beginning of the file. + * + * What follows is the official specification grammar for + * documents created or edited in Gutenberg. It starts at + * the top-level rule `Block_List` + * + * The grammar is defined by a series of _rules_ and ways + * to return matches on those rules. It's a _PEG_, a + * parsing expression grammar, which simply means that for + * each of our rules we have a set of sub-rules to match + * on and the generated parser will try them in order + * until it finds the first match. + * + * This grammar is a _specification_ (with as little actual + * code as we can get away with) which is used by the + * parser generator to generate the actual _parser_ which + * is used by Gutenberg. We generate two parsers: one in + * JavaScript for use the browser and one in PHP for + * WordPress itself. PEG parser generators are available + * in many languages, though different libraries may require + * some translation of this grammar into their syntax. + * + * For more information: + * @see https://pegjs.org + * @see https://en.wikipedia.org/wiki/Parsing_expression_grammar + * + */ + +/** <?php +// The `maybeJSON` function is not needed in PHP because its return semantics +// are the same as `json_decode` + +if ( ! function_exists( 'peg_empty_attrs' ) ) { + function peg_empty_attrs() { + static $empty_attrs = null; + + if ( null === $empty_attrs ) { + $empty_attrs = json_decode( '{}', true ); + } + + return $empty_attrs; + } +} + +// array arguments are backwards because of PHP +if ( ! function_exists( 'peg_process_inner_content' ) ) { + function peg_process_inner_content( $array ) { + $html = ''; + $blocks = array(); + $content = array(); + + foreach ( $array as $item ) { + if ( is_string( $item ) ) { + $html .= $item; + $content[] = $item; + } else { + $blocks[] = $item; + $content[] = null; + } + } + + return array( $html, $blocks, $content ); + } +} + +if ( ! function_exists( 'peg_join_blocks' ) ) { + function peg_join_blocks( $pre, $tokens, $post ) { + $blocks = array(); + + if ( ! empty( $pre ) ) { + $blocks[] = array( + 'blockName' => null, + 'attrs' => peg_empty_attrs(), + 'innerBlocks' => array(), + 'innerHTML' => $pre, + 'innerContent' => array( $pre ), + ); + } + + foreach ( $tokens as $token ) { + list( $token, $html ) = $token; + + $blocks[] = $token; + + if ( ! empty( $html ) ) { + $blocks[] = array( + 'blockName' => null, + 'attrs' => peg_empty_attrs(), + 'innerBlocks' => array(), + 'innerHTML' => $html, + 'innerContent' => array( $html ), + ); + } + } + + if ( ! empty( $post ) ) { + $blocks[] = array( + 'blockName' => null, + 'attrs' => peg_empty_attrs(), + 'innerBlocks' => array(), + 'innerHTML' => $post, + 'innerContent' => array( $post ), + ); + } + + return $blocks; + } +} + +?> **/ + +function freeform( s ) { + return s.length && { + blockName: null, + attrs: {}, + innerBlocks: [], + innerHTML: s, + innerContent: [ s ], + }; +} + +function joinBlocks( pre, tokens, post ) { + var blocks = [], i, l, html, item, token; + + if ( pre.length ) { + blocks.push( freeform( pre ) ); + } + + for ( i = 0, l = tokens.length; i < l; i++ ) { + item = tokens[ i ]; + token = item[ 0 ]; + html = item[ 1 ]; + + blocks.push( token ); + if ( html.length ) { + blocks.push( freeform( html ) ); + } + } + + if ( post.length ) { + blocks.push( freeform( post ) ); + } + + return blocks; +} + +function maybeJSON( s ) { + try { + return JSON.parse( s ); + } catch (e) { + return null; + } +} + +function processInnerContent( list ) { + var i, l, item; + var html = ''; + var blocks = []; + var content = []; + + // nod to performance over a simpler reduce + // and clone model we could have taken here + for ( i = 0, l = list.length; i < l; i++ ) { + item = list[ i ]; + + if ( 'string' === typeof item ) { + html += item; + content.push( item ); + } else { + blocks.push( item ); + content.push( null ); + } + }; + + return [ html, blocks, content ]; +} + +} + +////////////////////////////////////////////////////// +// +// Here starts the grammar proper! +// +////////////////////////////////////////////////////// + +Block_List + = pre:$(!Block .)* + bs:(b:Block html:$((!Block .)*) { /** <?php return array( $b, $html ); ?> **/ return [ b, html ] })* + post:$(.*) + { /** <?php return peg_join_blocks( $pre, $bs, $post ); ?> **/ + return joinBlocks( pre, bs, post ); + } + +Block + = Block_Void + / Block_Balanced + +Block_Void + = "<!--" __ "wp:" blockName:Block_Name __ attrs:(a:Block_Attributes __ { + /** <?php return $a; ?> **/ + return a; + })? "/-->" + { + /** <?php + return array( + 'blockName' => $blockName, + 'attrs' => empty( $attrs ) ? peg_empty_attrs() : $attrs, + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ); + ?> **/ + + return { + blockName: blockName, + attrs: attrs || {}, + innerBlocks: [], + innerHTML: '', + innerContent: [] + }; + } + +Block_Balanced + = s:Block_Start children:(Block / $((!Block !Block_End .)+))* e:Block_End + { + /** <?php + list( $innerHTML, $innerBlocks, $innerContent ) = peg_process_inner_content( $children ); + + return array( + 'blockName' => $s['blockName'], + 'attrs' => empty( $s['attrs'] ) ? peg_empty_attrs() : $s['attrs'], + 'innerBlocks' => $innerBlocks, + 'innerHTML' => $innerHTML, + 'innerContent' => $innerContent, + ); + ?> **/ + + var innerParts = processInnerContent( children ); + var innerHTML = innerParts[ 0 ]; + var innerBlocks = innerParts[ 1 ]; + var innerContent = innerParts[ 2 ]; + + return { + blockName: s.blockName, + attrs: s.attrs, + innerBlocks: innerBlocks, + innerHTML: innerHTML, + innerContent: innerContent, + }; + } + +Block_Start + = "<!--" __ "wp:" blockName:Block_Name __ attrs:(a:Block_Attributes __ { + /** <?php return $a; ?> **/ + return a; + })? "-->" + { + /** <?php + return array( + 'blockName' => $blockName, + 'attrs' => isset( $attrs ) ? $attrs : array(), + ); + ?> **/ + + return { + blockName: blockName, + attrs: attrs || {} + }; + } + +Block_End + = "<!--" __ "/wp:" blockName:Block_Name __ "-->" + { + /** <?php + return array( + 'blockName' => $blockName, + ); + ?> **/ + + return { + blockName: blockName + }; + } + +Block_Name + = Namespaced_Block_Name + / Core_Block_Name + +Namespaced_Block_Name + = $( Block_Name_Part "/" Block_Name_Part ) + +Core_Block_Name + = type:$( Block_Name_Part ) + { + /** <?php return "core/$type"; ?> **/ + return 'core/' + type; + } + +Block_Name_Part + = $( [a-z][a-z0-9_-]* ) + +Block_Attributes + "JSON-encoded attributes embedded in a block's opening comment" + = attrs:$("{" (!("}" __ """/"? "-->") .)* "}") + { + /** <?php return json_decode( $attrs, true ); ?> **/ + return maybeJSON( attrs ); + } + +__ + = [ \t\r\n]+ diff --git a/gb-src/packages/block-serialization-spec-parser/package.json b/gb-src/packages/block-serialization-spec-parser/package.json new file mode 100644 index 0000000000000..289e8b8e86afd --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wordpress/block-serialization-spec-parser", + "version": "3.3.1", + "description": "Block serialization specification parser for WordPress posts.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "block", + "spec", + "parser" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-serialization-spec-parser/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-serialization-spec-parser" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "parser.js", + "sideEffects": false, + "dependencies": { + "pegjs": "^0.10.0", + "phpegjs": "^1.0.0-beta7" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "concurrently \"npm run build:js\" \"npm run build:php\"", + "build:js": "pegjs --format commonjs -o ./parser.js ./grammar.pegjs", + "build:php": "node bin/create-php-parser.js" + } +} diff --git a/gb-src/packages/block-serialization-spec-parser/shared-tests.js b/gb-src/packages/block-serialization-spec-parser/shared-tests.js new file mode 100644 index 0000000000000..74c991b6bbd72 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/shared-tests.js @@ -0,0 +1,211 @@ +export const jsTester = ( parse ) => () => { + describe( 'output structure', () => { + test( 'output is an array', () => { + expect( parse( '' ) ).toEqual( expect.any( Array ) ); + expect( parse( 'test' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:void /-->' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:block --><!-- wp:inner /--><!-- /wp:block -->' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:first /--><!-- wp:second /-->' ) ).toEqual( expect.any( Array ) ); + } ); + + test( 'parses blocks of various types', () => { + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {"value":true} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {"a":{}} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void { "value" : true } /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {\n\t"value" : true\n} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {"value":true} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {} -->inner<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {"value":{"a" : "true"}} -->inner<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + } ); + + test( 'blockName is namespaced string (except freeform)', () => { + expect( parse( 'freeform has null name' )[ 0 ] ).toHaveProperty( 'blockName', null ); + expect( parse( '<!-- wp:more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/more' ); + expect( parse( '<!-- wp:core/more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/more' ); + expect( parse( '<!-- wp:my/more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'my/more' ); + } ); + + test( 'JSON attributes are key/value object', () => { + expect( parse( 'freeform has empty attrs' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void {"key": "value"} /-->' )[ 0 ] ).toHaveProperty( 'attrs', { key: 'value' } ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:block {} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:block {"key": "value"} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', { key: 'value' } ); + } ); + + test( 'innerBlocks is a list', () => { + expect( parse( 'freeform has empty innerBlocks' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + + const withInner = parse( '<!-- wp:block --><!-- wp:inner /--><!-- /wp:block -->' )[ 0 ]; + expect( withInner ).toHaveProperty( 'innerBlocks', expect.any( Array ) ); + expect( withInner.innerBlocks ).toHaveLength( 1 ); + + const withTwoInner = parse( '<!-- wp:block -->a<!-- wp:first /-->b<!-- wp:second /-->c<!-- /wp:block -->' )[ 0 ]; + expect( withTwoInner ).toHaveProperty( 'innerBlocks', expect.any( Array ) ); + expect( withTwoInner.innerBlocks ).toHaveLength( 2 ); + } ); + + test( 'innerHTML is a string', () => { + expect( parse( 'test' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test /-->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test --><!-- /wp:test -->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test -->test<!-- /wp:test -->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + } ); + } ); + + describe( 'generic tests', () => { + test( 'parse() accepts inputs with multiple Reusable blocks', () => { + expect( parse( '<!-- wp:block {"ref":313} /--><!-- wp:block {"ref":482} /-->' ) ).toEqual( [ + expect.objectContaining( { + blockName: 'core/block', + attrs: { ref: 313 }, + } ), + expect.objectContaining( { + blockName: 'core/block', + attrs: { ref: 482 }, + } ), + ] ); + } ); + + test( 'treats void blocks and empty blocks identically', () => { + expect( parse( + '<!-- wp:block /-->' + ) ).toEqual( parse( + '<!-- wp:block --><!-- /wp:block -->' + ) ); + + expect( parse( + '<!-- wp:my/bus { "is": "fast" } /-->' + ) ).toEqual( parse( + '<!-- wp:my/bus { "is": "fast" } --><!-- /wp:my/bus -->' + ) ); + } ); + + test( 'should grab HTML soup before block openers', () => { + [ + '<p>Break me</p><!-- wp:block /-->', + '<p>Break me</p><!-- wp:block --><!-- /wp:block -->', + ].forEach( ( input ) => expect( parse( input ) ).toEqual( [ + expect.objectContaining( { innerHTML: '<p>Break me</p>' } ), + expect.objectContaining( { blockName: 'core/block', innerHTML: '' } ), + ] ) ); + } ); + + test( 'should grab HTML soup before inner block openers', () => [ + '<!-- wp:outer --><p>Break me</p><!-- wp:block /--><!-- /wp:outer -->', + '<!-- wp:outer --><p>Break me</p><!-- wp:block --><!-- /wp:block --><!-- /wp:outer -->', + ].forEach( ( input ) => expect( parse( input ) ).toEqual( [ + expect.objectContaining( { + innerBlocks: [ expect.objectContaining( { blockName: 'core/block', innerHTML: '' } ) ], + innerHTML: '<p>Break me</p>', + } ), + ] ) ) ); + + test( 'should grab HTML soup after blocks', () => [ + '<!-- wp:block /--><p>Break me</p>', + '<!-- wp:block --><!-- /wp:block --><p>Break me</p>', + ].forEach( ( input ) => expect( parse( input ) ).toEqual( [ + expect.objectContaining( { blockName: 'core/block', innerHTML: '' } ), + expect.objectContaining( { innerHTML: '<p>Break me</p>' } ), + ] ) ) ); + } ); + + describe( 'innerBlock placemarkers', () => { + test( 'innerContent exists', () => { + expect( parse( 'test' )[ 0 ] ).toHaveProperty( 'innerContent', [ 'test' ] ); + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'innerContent', [] ); + } ); + + test( 'innerContent contains innerHTML', () => { + expect( parse( '<!-- wp:block -->Inner<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerContent', [ 'Inner' ] ); + } ); + + test( 'block locations become null', () => { + expect( parse( '<!-- wp:block --><!-- wp:void /--><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerContent', [ null ] ); + } ); + + test( 'HTML soup appears after blocks', () => { + expect( parse( '<!-- wp:block --><!-- wp:void /-->After<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerContent', [ null, 'After' ] ); + } ); + + test( 'HTML soup appears before blocks', () => { + expect( parse( '<!-- wp:block -->Before<!-- wp:void /--><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerContent', [ 'Before', null ] ); + } ); + + test( 'blocks follow each other', () => { + expect( parse( '<!-- wp:block --><!-- wp:void /--><!-- wp:void /--><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerContent', [ null, null ] ); + } ); + } ); + + describe( 'attack vectors', () => { + test( 'really long JSON attribute sections', () => { + const length = 100000; + const as = 'a'.repeat( length ); + let parsed; + + expect( () => parsed = parse( `<!-- wp:fake {"a":"${ as }"} /-->` )[ 0 ] ).not.toThrow(); + expect( parsed.attrs.a ).toHaveLength( length ); + } ); + + describe( 'invalid block comment syntax', () => { + test( 'extra space after void closer', () => { + let parsed; + + expect( () => parsed = parse( '<!-- wp:block / -->' )[ 0 ] ).not.toThrow(); + expect( parsed.blockName ).toBeNull(); + } ); + } ); + } ); +}; + +const hasPHP = 'test' === process.env.NODE_ENV ? ( () => { + const process = require( 'child_process' ).spawnSync( 'php', [ '-r', 'echo 1;' ], { encoding: 'utf8' } ); + + return process.status === 0 && process.stdout === '1'; +} )() : false; + +// skipping if `php` isn't available to us, such as in local dev without it +// skipping preserves snapshots while commenting out or simply +// not injecting the tests prompts `jest` to remove "obsolete snapshots" +// eslint-disable-next-line jest/no-disabled-tests, jest/valid-describe +const makeTest = hasPHP ? ( ...args ) => describe( ...args ) : ( ...args ) => describe.skip( ...args ); + +export const phpTester = ( name, filename ) => makeTest( + name, + 'test' === process.env.NODE_ENV ? jsTester( ( doc ) => { + const process = require( 'child_process' ).spawnSync( + 'php', + [ '-f', filename ], + { + input: doc, + encoding: 'utf8', + timeout: 30 * 1000, // abort after 30 seconds, that's too long anyway + } + ); + + if ( process.status !== 0 ) { + throw new Error( process.stderr || process.stdout ); + } + + try { + /* + * Due to an issue with PHP's json_encode() serializing an empty associative array + * as an empty list `[]` we're manually replacing the already-encoded bit here. + * + * This is an issue with the test runner, not with the parser. + */ + return JSON.parse( process.stdout.replace( /"attrs":\s*\[\]/g, '"attrs":{}' ) ); + } catch ( e ) { + throw new Error( 'failed to parse JSON:\n' + process.stdout ); + } + } ) : () => {} +); diff --git a/gb-src/packages/block-serialization-spec-parser/test/index.js b/gb-src/packages/block-serialization-spec-parser/test/index.js new file mode 100644 index 0000000000000..2b60ce2c11f1e --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/test/index.js @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import path from 'path'; + +/** + * Internal dependencies + */ +import { parse } from '../'; +import { jsTester, phpTester } from '../shared-tests'; + +describe( 'block-serialization-spec-parser-js', jsTester( parse ) ); // eslint-disable-line jest/valid-describe + +phpTester( 'block-serialization-spec-parser-php', path.join( __dirname, 'test-parser.php' ) ); diff --git a/gb-src/packages/block-serialization-spec-parser/test/test-parser.php b/gb-src/packages/block-serialization-spec-parser/test/test-parser.php new file mode 100644 index 0000000000000..a55183c44b8e5 --- /dev/null +++ b/gb-src/packages/block-serialization-spec-parser/test/test-parser.php @@ -0,0 +1,15 @@ +<?php +/** + * PHP Test Helper + * + * Facilitates running PHP parser against same tests as the JS parser implementation. + * + * @package gutenberg + */ + +// Include the generated parser. +require_once dirname( __FILE__ ) . '/../parser.php'; + +$parser = new Gutenberg_PEG_Parser(); + +echo json_encode( $parser->parse( file_get_contents( 'php://stdin' ) ) ); diff --git a/gb-src/packages/blocks/.npmrc b/gb-src/packages/blocks/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/blocks/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/blocks/CHANGELOG.md b/gb-src/packages/blocks/CHANGELOG.md new file mode 100644 index 0000000000000..82d571202f45f --- /dev/null +++ b/gb-src/packages/blocks/CHANGELOG.md @@ -0,0 +1,108 @@ +## 6.4.0 (2019-08-05) + +### Improvements + +- Omitting `attributes` or `keywords` settings will now stub default values (an empty object or empty array, respectively). + +### Bug Fixes + +- The `'blocks.registerBlockType'` filter is now applied to each of a block's deprecated settings as well as the block's main settings. Ensures `supports` settings like `anchor` work for deprecations. + +## 6.3.0 (2019-05-21) + +### New Feature + +- Added a default implementation for `save` setting in `registerBlockType` which saves no markup in the post content. +- Added wildcard block transforms which allows for transforming all/any blocks in another block. + +## 6.1.0 (2019-03-06) + +### New Feature + +- Blocks' `transforms` will receive `innerBlocks` as the second argument (or an array of each block's respective `innerBlocks` for a multi-transform). + +### Bug Fixes + +- Block validation will now correctly validate character references, resolving some issues where a standalone ampersand `&` followed later in markup by a character reference (e.g. `&`) could wrongly mark a block as being invalid. ([#13512](https://github.com/WordPress/gutenberg/pull/13512)) + +## 6.0.5 (2019-01-03) + +## 6.0.4 (2018-12-12) + +## 6.0.3 (2018-11-30) + +## 6.0.2 (2018-11-21) + +## 6.0.1 (2018-11-20) + +## 6.0.0 (2018-11-15) + +### Breaking Changes + +- `isValidBlock` has been removed. Please use `isValidBlockContent` instead but keep in mind that the order of params has changed. + +### Bug Fix + +- The block validator is more lenient toward equivalent encoding forms. + +## 5.3.1 (2018-11-12) + +## 5.3.0 (2018-11-09) + +### New feature + +- `getBlockAttributes`, `getBlockTransforms`, `getSaveContent`, `getSaveElement` and `isValidBlockContent` methods can now take also block's name as the first param ([#11490](https://github.com/WordPress/gutenberg/pull/11490)). Passing a block's type object continues to work as before. +- `registerBlockStyles` and `unregisterBlockStyles` can be triggered at any moment (before or after block registration). + +## 5.2.0 (2018-11-09) + +- Paste: Google Docs: fix nested formatting, sub, sup and del. +- Expose @wordpress/editor to Gutenberg mobile. +- Separate Paste Handler. + +## 5.1.2 (2018-11-03) + +## 5.1.1 (2018-10-30) + +## 5.1.0 (2018-10-30) + +### New features + +- `isValidBlockContent` function has been added ([#10891](https://github.com/WordPress/gutenberg/pull/10891)). + +### Deprecation + +- `isValidBlock` function has been deprecated ([#10891](https://github.com/WordPress/gutenberg/pull/10891)). Use `isValidBlockContent` instead. + +## 5.0.0 (2018-10-29) + +### Breaking Changes + +- Attribute type coercion has been removed. Omit the source to preserve type via serialized comment demarcation. +- `setUnknownTypeHandlerName` has been removed. Please use `setFreeformContentHandlerName` and `setUnregisteredTypeHandlerName` instead. +- `getUnknownTypeHandlerName` has been removed. Please use `getFreeformContentHandlerName` and `getUnregisteredTypeHandlerName` instead. + +### New Feature + +- Added a `unregisterBlockStyle()` function to remove a block style variation. + +## 4.0.4 (2018-10-19) + +## 4.0.3 (2018-10-18) + +## 4.0.0 (2018-09-30) + +### Breaking Changes + +- `getDefaultBlockForPostFormat` has been removed. + +## 3.0.0 (2018-09-05) + +### Breaking Changes + +- The `isSharedBlock` function is removed. Use `isReusableBlock` instead. +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +### Deprecations + +- The `getDefaultBlockForPostFormat` function has been deprecated. diff --git a/gb-src/packages/blocks/README.md b/gb-src/packages/blocks/README.md new file mode 100644 index 0000000000000..1af3f49adef88 --- /dev/null +++ b/gb-src/packages/blocks/README.md @@ -0,0 +1,776 @@ +# Blocks + +"Block" is the abstract term used to describe units of markup that, composed together, form the content or layout of a webpage. The idea combines concepts of what in WordPress today we achieve with shortcodes, custom HTML, and embed discovery into a single consistent API and user experience. + +For more context, refer to [_What Are Little Blocks Made Of?_](https://make.wordpress.org/design/2017/01/25/what-are-little-blocks-made-of/) from the [Make WordPress Design](https://make.wordpress.org/design/) blog. + +The following documentation outlines steps you as a developer will need to follow to add your own custom blocks to WordPress's editor interfaces. + +## Installation + +Install the module + +```bash +npm install @wordpress/blocks --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Getting Started + +If you're not already accustomed to working with JavaScript in your WordPress plugins, you may first want to reference the guide on [_Including CSS & JavaScript_](https://developer.wordpress.org/themes/basics/including-css-javascript/) in the Theme Handbook. + +At a minimum, you will need to enqueue scripts for your block as part of a `enqueue_block_editor_assets` action callback, with a dependency on the `wp-blocks` and `wp-element` script handles: + +```php +<?php +// myplugin.php + +function myplugin_enqueue_block_editor_assets() { + wp_enqueue_script( + 'myplugin-block', + plugins_url( 'block.js', __FILE__ ), + array( 'wp-blocks', 'wp-element' ) + ); +} +add_action( 'enqueue_block_editor_assets', 'myplugin_enqueue_block_editor_assets' ); +``` + +The `enqueue_block_editor_assets` hook is only run in the Gutenberg editor context when the editor is ready to receive additional scripts and stylesheets. There is also an `enqueue_block_assets` hook which is run under **both** the editor and front-end contexts. This should be used to enqueue stylesheets common to the front-end and the editor. (The rules can be overridden in the editor-specific stylesheet if necessary.) + +The following sections will describe what you'll need to include in `block.js` to describe the behavior of your custom block. + +Note that all JavaScript code samples in this document are enclosed in a function that is evaluated immediately afterwards. We recommend using either ES6 modules [as used in this project](/docs/contributors/develop/coding-guidelines.md#imports) (documentation on setting up a plugin with Webpack + ES6 modules coming soon) or these ["immediately-invoked function expressions"](https://en.wikipedia.org/wiki/Immediately-invoked_function_expression) as used in this document. Both of these methods ensure that your plugin's variables will not pollute the global `window` object, which could cause incompatibilities with WordPress core or with other plugins. + +## Example + +Let's imagine you wanted to define a block to show a randomly generated image in a post's content using [lorempixel.com](http://lorempixel.com/). The service provides a choice of category and you'd like to offer this as an option when editing the post. + +Take a step back and consider the ideal workflow for adding a new random image: + +- Insert the block. It should be shown in some empty state, with an option to choose a category in a select dropdown. +- Upon confirming my selection, a preview of the image should be shown next to the dropdown. + +At this point, you might realize that while you'd want some controls to be shown when editing content, the markup included in the published post might not appear the same (your visitors should not see a dropdown field when reading your content). + +This leads to the first requirement of describing a block: + +**You will need to provide implementations both for what's to be shown in an editor and what's to be saved with the published content**. + +To eliminate redundant effort here, share common behaviors by splitting your code up into [components](/packages/element/README.md). + +Now that we've considered user interaction, you should think about the underlying values that determine the markup generated by your block. In our example, the output is affected only when the category changes. Put another way: **the output of a block is a function of its attributes**. + +The category, a simple string, is the only thing we require to be able to generate the image we want to include in the published content. We call these underlying values of a block instance its **attributes**. + +With these concepts in mind, let's explore an implementation of our random image block: + +```php +<?php +// random-image.php + +function random_image_enqueue_block_editor_assets() { + wp_enqueue_script( + 'random-image-block', + plugins_url( 'block.js', __FILE__ ), + array( 'wp-blocks', 'wp-element' ) + ); +} +add_action( 'enqueue_block_editor_assets', 'random_image_enqueue_block_editor_assets' ); +``` + +```js +// block.js +( function( blocks, element ) { + var el = element.createElement, + source = blocks.source; + + function RandomImage( props ) { + var src = 'http://lorempixel.com/400/200/' + props.category; + + return el( 'img', { + src: src, + alt: props.category + } ); + } + + blocks.registerBlockType( 'myplugin/random-image', { + title: 'Random Image', + + icon: 'format-image', + + category: 'common', + + attributes: { + category: { + type: 'string', + source: 'attribute', + attribute: 'alt', + selector: 'img', + } + }, + + edit: function( props ) { + var category = props.attributes.category, + children; + + function setCategory( event ) { + var selected = event.target.querySelector( 'option:checked' ); + props.setAttributes( { category: selected.value } ); + event.preventDefault(); + } + + children = []; + if ( category ) { + children.push( RandomImage( { category: category } ) ); + } + + children.push( + el( 'select', { value: category, onChange: setCategory }, + el( 'option', null, '- Select -' ), + el( 'option', { value: 'sports' }, 'Sports' ), + el( 'option', { value: 'animals' }, 'Animals' ), + el( 'option', { value: 'nature' }, 'Nature' ) + ) + ); + + return el( 'form', { onSubmit: setCategory }, children ); + }, + + save: function( props ) { + return RandomImage( { category: props.attributes.category } ); + } + } ); +} )( + window.wp.blocks, + window.wp.element +); +``` + +_[(Example in ES2015+, JSX)](https://gist.github.com/aduth/fb1cc9a2296110a62b96383e4b2e8a7c)_ + +Let's briefly review a few items you might observe in the implementation: + +- When registering a new block, you must prefix its name with a namespace for + your plugin. This helps prevent conflicts when more than one plugin registers + a block with the same name. +- You will use `createElement` to describe the structure of your block's + markup. See the [Element documentation](/packages/element/README.md) for more + information. +- Extracting `RandomImage` to a separate function allows us to reuse it in both + the editor-specific interface and the published content. +- The `edit` function should handle any case where an attribute is unset, as in + the case of the block being newly inserted. +- We only change the attributes of a block by calling the `setAttributes` + helper. Never assign a value on the attributes object directly. +- React provides conveniences for working with `select` element with + [`value` and `onChange` props](https://facebook.github.io/react/docs/forms.html#the-select-tag). + +By concerning yourself only with describing the markup of a block given its attributes, you need not worry about maintaining the state of the page, or how your block interacts in the context of the surrounding editor. + +But how does the markup become an object of attributes? We need a pattern for encoding the values into the published post's markup, and then retrieving them the next time the post is edited. This is the motivation for the block's `attributes` property. The shape of this object matches that of the attributes object we'd like to receive, where each value is a [**source**](http://github.com/aduth/hpq) which tries to find the desired value from the markup of the block. + +In the random image block above, we've given the `alt` attribute of the image a secondary responsibility of tracking the selected category. There are a few other ways we could have achieved this, but the category value happens to work well as an `alt` descriptor. In the `attributes` property, we define an object with a key of `category` whose value tries to find this `alt` attribute of the markup. If it's successful, the category's value in our `edit` and `save` functions will be assigned. In the case of a new block or invalid markup, this value would be `undefined`, so we adjust our return value accordingly. + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +<a name="cloneBlock" href="#cloneBlock">#</a> **cloneBlock** + +Given a block object, returns a copy of the block object, optionally merging +new attributes and/or replacing its inner blocks. + +_Parameters_ + +- _block_ `Object`: Block instance. +- _mergeAttributes_ `Object`: Block attributes. +- _newInnerBlocks_ `?Array`: Nested blocks. + +_Returns_ + +- `Object`: A cloned block. + +<a name="createBlock" href="#createBlock">#</a> **createBlock** + +Returns a block object given its type and attributes. + +_Parameters_ + +- _name_ `string`: Block name. +- _attributes_ `Object`: Block attributes. +- _innerBlocks_ `?Array`: Nested blocks. + +_Returns_ + +- `Object`: Block object. + +<a name="doBlocksMatchTemplate" href="#doBlocksMatchTemplate">#</a> **doBlocksMatchTemplate** + +Checks whether a list of blocks matches a template by comparing the block names. + +_Parameters_ + +- _blocks_ `Array`: Block list. +- _template_ `Array`: Block template. + +_Returns_ + +- `boolean`: Whether the list of blocks matches a templates + +<a name="findTransform" href="#findTransform">#</a> **findTransform** + +Given an array of transforms, returns the highest-priority transform where +the predicate function returns a truthy value. A higher-priority transform +is one with a lower priority value (i.e. first in priority order). Returns +null if the transforms set is empty or the predicate function returns a +falsey value for all entries. + +_Parameters_ + +- _transforms_ `Array<Object>`: Transforms to search. +- _predicate_ `Function`: Function returning true on matching transform. + +_Returns_ + +- `?Object`: Highest-priority transform candidate. + +<a name="getBlockAttributes" href="#getBlockAttributes">#</a> **getBlockAttributes** + +Returns the block attributes of a registered block node given its type. + +_Parameters_ + +- _blockTypeOrName_ `(string|Object)`: Block type or name. +- _innerHTML_ `string`: Raw block content. +- _attributes_ `?Object`: Known block attributes (from delimiters). + +_Returns_ + +- `Object`: All block attributes. + +<a name="getBlockContent" href="#getBlockContent">#</a> **getBlockContent** + +Given a block object, returns the Block's Inner HTML markup. + +_Parameters_ + +- _block_ `Object`: Block instance. + +_Returns_ + +- `string`: HTML. + +<a name="getBlockDefaultClassName" href="#getBlockDefaultClassName">#</a> **getBlockDefaultClassName** + +Returns the block's default classname from its name. + +_Parameters_ + +- _blockName_ `string`: The block name. + +_Returns_ + +- `string`: The block's default class. + +<a name="getBlockFromExample" href="#getBlockFromExample">#</a> **getBlockFromExample** + +Create a block object from the example API. + +_Parameters_ + +- _name_ `string`: +- _example_ `Object`: + +_Returns_ + +- `Object`: block. + +<a name="getBlockMenuDefaultClassName" href="#getBlockMenuDefaultClassName">#</a> **getBlockMenuDefaultClassName** + +Returns the block's default menu item classname from its name. + +_Parameters_ + +- _blockName_ `string`: The block name. + +_Returns_ + +- `string`: The block's default menu item class. + +<a name="getBlockSupport" href="#getBlockSupport">#</a> **getBlockSupport** + +Returns the block support value for a feature, if defined. + +_Parameters_ + +- _nameOrType_ `(string|Object)`: Block name or type object +- _feature_ `string`: Feature to retrieve +- _defaultSupports_ `*`: Default value to return if not explicitly defined + +_Returns_ + +- `?*`: Block support value + +<a name="getBlockTransforms" href="#getBlockTransforms">#</a> **getBlockTransforms** + +Returns normal block transforms for a given transform direction, optionally +for a specific block by name, or an empty array if there are no transforms. +If no block name is provided, returns transforms for all blocks. A normal +transform object includes `blockName` as a property. + +_Parameters_ + +- _direction_ `string`: Transform direction ("to", "from"). +- _blockTypeOrName_ `(string|Object)`: Block type or name. + +_Returns_ + +- `Array`: Block transforms for direction. + +<a name="getBlockType" href="#getBlockType">#</a> **getBlockType** + +Returns a registered block type. + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `?Object`: Block type. + +<a name="getBlockTypes" href="#getBlockTypes">#</a> **getBlockTypes** + +Returns all registered blocks. + +_Returns_ + +- `Array`: Block settings. + +<a name="getCategories" href="#getCategories">#</a> **getCategories** + +Returns all the block categories. + +_Returns_ + +- `Array<Object>`: Block categories. + +<a name="getChildBlockNames" href="#getChildBlockNames">#</a> **getChildBlockNames** + +Returns an array with the child blocks of a given block. + +_Parameters_ + +- _blockName_ `string`: Name of block (example: “latest-posts”). + +_Returns_ + +- `Array`: Array of child block names. + +<a name="getDefaultBlockName" href="#getDefaultBlockName">#</a> **getDefaultBlockName** + +Retrieves the default block name. + +_Returns_ + +- `?string`: Block name. + +<a name="getFreeformContentHandlerName" href="#getFreeformContentHandlerName">#</a> **getFreeformContentHandlerName** + +Retrieves name of block handling non-block content, or undefined if no +handler has been defined. + +_Returns_ + +- `?string`: Block name. + +<a name="getGroupingBlockName" href="#getGroupingBlockName">#</a> **getGroupingBlockName** + +Retrieves name of block used for handling grouping interactions. + +_Returns_ + +- `?string`: Block name. + +<a name="getPhrasingContentSchema" href="#getPhrasingContentSchema">#</a> **getPhrasingContentSchema** + +Get schema of possible paths for phrasing content. + +_Related_ + +- <https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content> + +_Returns_ + +- `Object`: Schema. + +<a name="getPossibleBlockTransformations" href="#getPossibleBlockTransformations">#</a> **getPossibleBlockTransformations** + +Returns an array of block types that the set of blocks received as argument +can be transformed into. + +_Parameters_ + +- _blocks_ `Array`: Blocks array. + +_Returns_ + +- `Array`: Block types that the blocks argument can be transformed to. + +<a name="getSaveContent" href="#getSaveContent">#</a> **getSaveContent** + +Given a block type containing a save render implementation and attributes, returns the +static markup to be saved. + +_Parameters_ + +- _blockTypeOrName_ `(string|Object)`: Block type or name. +- _attributes_ `Object`: Block attributes. +- _innerBlocks_ `?Array`: Nested blocks. + +_Returns_ + +- `string`: Save content. + +<a name="getSaveElement" href="#getSaveElement">#</a> **getSaveElement** + +Given a block type containing a save render implementation and attributes, returns the +enhanced element to be saved or string when raw HTML expected. + +_Parameters_ + +- _blockTypeOrName_ `(string|Object)`: Block type or name. +- _attributes_ `Object`: Block attributes. +- _innerBlocks_ `?Array`: Nested blocks. + +_Returns_ + +- `(Object|string)`: Save element or raw HTML string. + +<a name="getUnregisteredTypeHandlerName" href="#getUnregisteredTypeHandlerName">#</a> **getUnregisteredTypeHandlerName** + +Retrieves name of block handling unregistered block types, or undefined if no +handler has been defined. + +_Returns_ + +- `?string`: Block name. + +<a name="hasBlockSupport" href="#hasBlockSupport">#</a> **hasBlockSupport** + +Returns true if the block defines support for a feature, or false otherwise. + +_Parameters_ + +- _nameOrType_ `(string|Object)`: Block name or type object. +- _feature_ `string`: Feature to test. +- _defaultSupports_ `boolean`: Whether feature is supported by default if not explicitly defined. + +_Returns_ + +- `boolean`: Whether block supports feature. + +<a name="hasChildBlocks" href="#hasChildBlocks">#</a> **hasChildBlocks** + +Returns a boolean indicating if a block has child blocks or not. + +_Parameters_ + +- _blockName_ `string`: Name of block (example: “latest-posts”). + +_Returns_ + +- `boolean`: True if a block contains child blocks and false otherwise. + +<a name="hasChildBlocksWithInserterSupport" href="#hasChildBlocksWithInserterSupport">#</a> **hasChildBlocksWithInserterSupport** + +Returns a boolean indicating if a block has at least one child block with inserter support. + +_Parameters_ + +- _blockName_ `string`: Block type name. + +_Returns_ + +- `boolean`: True if a block contains at least one child blocks with inserter support and false otherwise. + +<a name="isReusableBlock" href="#isReusableBlock">#</a> **isReusableBlock** + +Determines whether or not the given block is a reusable block. This is a +special block type that is used to point to a global block stored via the +API. + +_Parameters_ + +- _blockOrType_ `Object`: Block or Block Type to test. + +_Returns_ + +- `boolean`: Whether the given block is a reusable block. + +<a name="isUnmodifiedDefaultBlock" href="#isUnmodifiedDefaultBlock">#</a> **isUnmodifiedDefaultBlock** + +Determines whether the block is a default block +and its attributes are equal to the default attributes +which means the block is unmodified. + +_Parameters_ + +- _block_ `WPBlock`: Block Object + +_Returns_ + +- `boolean`: Whether the block is an unmodified default block + +<a name="isValidBlockContent" href="#isValidBlockContent">#</a> **isValidBlockContent** + +Returns true if the parsed block is valid given the input content. A block +is considered valid if, when serialized with assumed attributes, the content +matches the original value. + +Logs to console in development environments when invalid. + +_Parameters_ + +- _blockTypeOrName_ `(string|Object)`: Block type. +- _attributes_ `Object`: Parsed block attributes. +- _originalBlockContent_ `string`: Original block content. + +_Returns_ + +- `boolean`: Whether block is valid. + +<a name="isValidIcon" href="#isValidIcon">#</a> **isValidIcon** + +Function that checks if the parameter is a valid icon. + +_Parameters_ + +- _icon_ `*`: Parameter to be checked. + +_Returns_ + +- `boolean`: True if the parameter is a valid icon and false otherwise. + +<a name="normalizeIconObject" href="#normalizeIconObject">#</a> **normalizeIconObject** + +Function that receives an icon as set by the blocks during the registration +and returns a new icon object that is normalized so we can rely on just on possible icon structure +in the codebase. + +_Parameters_ + +- _icon_ `WPBlockTypeIconRender`: Render behavior of a block type icon; one of a Dashicon slug, an element, or a component. + +_Returns_ + +- `WPBlockTypeIconDescriptor`: Object describing the icon. + +<a name="parse" href="#parse">#</a> **parse** + +Parses the post content with a PegJS grammar and returns a list of blocks. + +_Parameters_ + +- _content_ `string`: The post content. + +_Returns_ + +- `Array`: Block list. + +<a name="parseWithAttributeSchema" href="#parseWithAttributeSchema">#</a> **parseWithAttributeSchema** + +Given a block's raw content and an attribute's schema returns the attribute's +value depending on its source. + +_Parameters_ + +- _innerHTML_ `string`: Block's raw content. +- _attributeSchema_ `Object`: Attribute's schema. + +_Returns_ + +- `*`: Attribute value. + +<a name="pasteHandler" href="#pasteHandler">#</a> **pasteHandler** + +Converts an HTML string to known blocks. Strips everything else. + +_Parameters_ + +- _options_ `Object`: +- _options.HTML_ `[string]`: The HTML to convert. +- _options.plainText_ `[string]`: Plain text version. +- _options.mode_ `[string]`: Handle content as blocks or inline content. _ 'AUTO': Decide based on the content passed. _ 'INLINE': Always handle as inline content, and return string. \* 'BLOCKS': Always handle as blocks, and return array of blocks. +- _options.tagName_ `[Array]`: The tag into which content will be inserted. +- _options.canUserUseUnfilteredHTML_ `[boolean]`: Whether or not the user can use unfiltered HTML. + +_Returns_ + +- `(Array|string)`: A list of blocks or a string, depending on `handlerMode`. + +<a name="rawHandler" href="#rawHandler">#</a> **rawHandler** + +Converts an HTML string to known blocks. + +_Parameters_ + +- _$1_ `Object`: +- _$1.HTML_ `string`: The HTML to convert. + +_Returns_ + +- `Array`: A list of blocks. + +<a name="registerBlockStyle" href="#registerBlockStyle">#</a> **registerBlockStyle** + +Registers a new block style variation for the given block. + +_Parameters_ + +- _blockName_ `string`: Name of block (example: “core/latest-posts”). +- _styleVariation_ `Object`: Object containing `name` which is the class name applied to the block and `label` which identifies the variation to the user. + +<a name="registerBlockType" href="#registerBlockType">#</a> **registerBlockType** + +Registers a new block provided a unique name and an object defining its +behavior. Once registered, the block is made available as an option to any +editor interface where blocks are implemented. + +_Parameters_ + +- _name_ `string`: Block name. +- _settings_ `Object`: Block settings. + +_Returns_ + +- `?WPBlock`: The block, if it has been successfully registered; otherwise `undefined`. + +<a name="serialize" href="#serialize">#</a> **serialize** + +Takes a block or set of blocks and returns the serialized post content. + +_Parameters_ + +- _blocks_ `Array`: Block(s) to serialize. +- _options_ `WPBlockSerializationOptions`: Serialization options. + +_Returns_ + +- `string`: The post content. + +<a name="setCategories" href="#setCategories">#</a> **setCategories** + +Sets the block categories. + +_Parameters_ + +- _categories_ `Array<Object>`: Block categories. + +<a name="setDefaultBlockName" href="#setDefaultBlockName">#</a> **setDefaultBlockName** + +Assigns the default block name. + +_Parameters_ + +- _name_ `string`: Block name. + +<a name="setFreeformContentHandlerName" href="#setFreeformContentHandlerName">#</a> **setFreeformContentHandlerName** + +Assigns name of block for handling non-block content. + +_Parameters_ + +- _blockName_ `string`: Block name. + +<a name="setGroupingBlockName" href="#setGroupingBlockName">#</a> **setGroupingBlockName** + +Assigns name of block for handling block grouping interactions. + +_Parameters_ + +- _name_ `string`: Block name. + +<a name="setUnregisteredTypeHandlerName" href="#setUnregisteredTypeHandlerName">#</a> **setUnregisteredTypeHandlerName** + +Assigns name of block handling unregistered block types. + +_Parameters_ + +- _blockName_ `string`: Block name. + +<a name="switchToBlockType" href="#switchToBlockType">#</a> **switchToBlockType** + +Switch one or more blocks into one or more blocks of the new block type. + +_Parameters_ + +- _blocks_ `(Array|Object)`: Blocks array or block object. +- _name_ `string`: Block name. + +_Returns_ + +- `?Array`: Array of blocks or null. + +<a name="synchronizeBlocksWithTemplate" href="#synchronizeBlocksWithTemplate">#</a> **synchronizeBlocksWithTemplate** + +Synchronize a block list with a block template. + +Synchronizing a block list with a block template means that we loop over the blocks +keep the block as is if it matches the block at the same position in the template +(If it has the same name) and if doesn't match, we create a new block based on the template. +Extra blocks not present in the template are removed. + +_Parameters_ + +- _blocks_ `Array`: Block list. +- _template_ `Array`: Block template. + +_Returns_ + +- `Array`: Updated Block list. + +<a name="unregisterBlockStyle" href="#unregisterBlockStyle">#</a> **unregisterBlockStyle** + +Unregisters a block style variation for the given block. + +_Parameters_ + +- _blockName_ `string`: Name of block (example: “core/latest-posts”). +- _styleVariationName_ `string`: Name of class applied to the block. + +<a name="unregisterBlockType" href="#unregisterBlockType">#</a> **unregisterBlockType** + +Unregisters a block. + +_Parameters_ + +- _name_ `string`: Block name. + +_Returns_ + +- `?WPBlock`: The previous block value, if it has been successfully unregistered; otherwise `undefined`. + +<a name="updateCategory" href="#updateCategory">#</a> **updateCategory** + +Updates a category. + +_Parameters_ + +- _slug_ `string`: Block category slug. +- _category_ `Object`: Object containing the category properties that should be updated. + +<a name="withBlockContentContext" href="#withBlockContentContext">#</a> **withBlockContentContext** + +A Higher Order Component used to inject BlockContent using context to the +wrapped component. + +_Returns_ + +- `Component`: Enhanced component with injected BlockContent as prop. + + +<!-- END TOKEN(Autogenerated API docs) --> + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/blocks/package.json b/gb-src/packages/blocks/package.json new file mode 100644 index 0000000000000..b6d981b395ef8 --- /dev/null +++ b/gb-src/packages/blocks/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wordpress/blocks", + "version": "6.7.3", + "description": "Block API for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "blocks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/blocks/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/blocks" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/autop": "file:../autop", + "@wordpress/blob": "file:../blob", + "@wordpress/block-serialization-default-parser": "file:../block-serialization-default-parser", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", + "@wordpress/html-entities": "file:../html-entities", + "@wordpress/i18n": "file:../i18n", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/shortcode": "file:../shortcode", + "hpq": "^1.3.0", + "lodash": "^4.17.15", + "rememo": "^3.0.0", + "showdown": "^1.8.6", + "simple-html-tokenizer": "^0.5.7", + "tinycolor2": "^1.4.1", + "uuid": "^3.3.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/blocks/src/api/categories.js b/gb-src/packages/blocks/src/api/categories.js new file mode 100644 index 0000000000000..55945bc973bfc --- /dev/null +++ b/gb-src/packages/blocks/src/api/categories.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { dispatch, select } from '@wordpress/data'; + +/** + * Returns all the block categories. + * + * @return {Object[]} Block categories. + */ +export function getCategories() { + return select( 'core/blocks' ).getCategories(); +} + +/** + * Sets the block categories. + * + * @param {Object[]} categories Block categories. + */ +export function setCategories( categories ) { + dispatch( 'core/blocks' ).setCategories( categories ); +} + +/** + * Updates a category. + * + * @param {string} slug Block category slug. + * @param {Object} category Object containing the category properties that should be updated. + */ +export function updateCategory( slug, category ) { + dispatch( 'core/blocks' ).updateCategory( slug, category ); +} diff --git a/gb-src/packages/blocks/src/api/children.js b/gb-src/packages/blocks/src/api/children.js new file mode 100644 index 0000000000000..7cf70d2da7815 --- /dev/null +++ b/gb-src/packages/blocks/src/api/children.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as node from './node'; + +/** + * A representation of a block's rich text value. + * + * @typedef {WPBlockNode[]} WPBlockChildren + */ + +/** + * Given block children, returns a serialize-capable WordPress element. + * + * @param {WPBlockChildren} children Block children object to convert. + * + * @return {WPElement} A serialize-capable element. + */ +export function getSerializeCapableElement( children ) { + // The fact that block children are compatible with the element serializer is + // merely an implementation detail that currently serves to be true, but + // should not be mistaken as being a guarantee on the external API. The + // public API only offers guarantees to work with strings (toHTML) and DOM + // elements (fromDOM), and should provide utilities to manipulate the value + // rather than expect consumers to inspect or construct its shape (concat). + return children; +} + +/** + * Given block children, returns an array of block nodes. + * + * @param {WPBlockChildren} children Block children object to convert. + * + * @return {Array<WPBlockNode>} An array of individual block nodes. + */ +function getChildrenArray( children ) { + // The fact that block children are compatible with the element serializer + // is merely an implementation detail that currently serves to be true, but + // should not be mistaken as being a guarantee on the external API. + return children; +} + +/** + * Given two or more block nodes, returns a new block node representing a + * concatenation of its values. + * + * @param {...WPBlockChildren} blockNodes Block nodes to concatenate. + * + * @return {WPBlockChildren} Concatenated block node. + */ +export function concat( ...blockNodes ) { + const result = []; + for ( let i = 0; i < blockNodes.length; i++ ) { + const blockNode = castArray( blockNodes[ i ] ); + for ( let j = 0; j < blockNode.length; j++ ) { + const child = blockNode[ j ]; + const canConcatToPreviousString = ( + typeof child === 'string' && + typeof result[ result.length - 1 ] === 'string' + ); + + if ( canConcatToPreviousString ) { + result[ result.length - 1 ] += child; + } else { + result.push( child ); + } + } + } + + return result; +} + +/** + * Given an iterable set of DOM nodes, returns equivalent block children. + * Ignores any non-element/text nodes included in set. + * + * @param {Iterable.<Node>} domNodes Iterable set of DOM nodes to convert. + * + * @return {WPBlockChildren} Block children equivalent to DOM nodes. + */ +export function fromDOM( domNodes ) { + const result = []; + for ( let i = 0; i < domNodes.length; i++ ) { + try { + result.push( node.fromDOM( domNodes[ i ] ) ); + } catch ( error ) { + // Simply ignore if DOM node could not be converted. + } + } + + return result; +} + +/** + * Given a block node, returns its HTML string representation. + * + * @param {WPBlockChildren} children Block node(s) to convert to string. + * + * @return {string} String HTML representation of block node. + */ +export function toHTML( children ) { + const element = getSerializeCapableElement( children ); + + return renderToString( element ); +} + +/** + * Given a selector, returns an hpq matcher generating a WPBlockChildren value + * matching the selector result. + * + * @param {string} selector DOM selector. + * + * @return {Function} hpq matcher. + */ +export function matcher( selector ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + if ( match ) { + return fromDOM( match.childNodes ); + } + + return []; + }; +} + +/** + * Object of utility functions used in managing block attribute values of + * source `children`. + * + * @see https://github.com/WordPress/gutenberg/pull/10439 + * + * @deprecated since 4.0. The `children` source should not be used, and can be + * replaced by the `html` source. + * + * @private + */ +export default { + concat, + getChildrenArray, + fromDOM, + toHTML, + matcher, +}; diff --git a/gb-src/packages/blocks/src/api/constants.js b/gb-src/packages/blocks/src/api/constants.js new file mode 100644 index 0000000000000..9207e7392f295 --- /dev/null +++ b/gb-src/packages/blocks/src/api/constants.js @@ -0,0 +1,12 @@ +/** + * Array of valid keys in a block type settings deprecation object. + * + * @type {string[]} + */ +export const DEPRECATED_ENTRY_KEYS = [ + 'attributes', + 'supports', + 'save', + 'migrate', + 'isEligible', +]; diff --git a/gb-src/packages/blocks/src/api/factory.js b/gb-src/packages/blocks/src/api/factory.js new file mode 100644 index 0000000000000..fae2e0e4da9c0 --- /dev/null +++ b/gb-src/packages/blocks/src/api/factory.js @@ -0,0 +1,478 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; +import { + every, + reduce, + castArray, + findIndex, + isObjectLike, + filter, + first, + flatMap, + has, + uniq, + isFunction, + isEmpty, + map, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHooks, applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { getBlockType, getBlockTypes, getGroupingBlockName } from './registration'; +import { normalizeBlockType } from './utils'; + +/** + * Returns a block object given its type and attributes. + * + * @param {string} name Block name. + * @param {Object} attributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. + * + * @return {Object} Block object. + */ +export function createBlock( name, attributes = {}, innerBlocks = [] ) { + // Get the type definition associated with a registered block. + const blockType = getBlockType( name ); + + // Ensure attributes contains only values defined by block type, and merge + // default values for missing attributes. + const sanitizedAttributes = reduce( blockType.attributes, ( result, schema, key ) => { + const value = attributes[ key ]; + + if ( undefined !== value ) { + result[ key ] = value; + } else if ( schema.hasOwnProperty( 'default' ) ) { + result[ key ] = schema.default; + } + + if ( [ 'node', 'children' ].indexOf( schema.source ) !== -1 ) { + // Ensure value passed is always an array, which we're expecting in + // the RichText component to handle the deprecated value. + if ( typeof result[ key ] === 'string' ) { + result[ key ] = [ result[ key ] ]; + } else if ( ! Array.isArray( result[ key ] ) ) { + result[ key ] = []; + } + } + + return result; + }, {} ); + + const clientId = uuid(); + + // Blocks are stored with a unique ID, the assigned type name, the block + // attributes, and their inner blocks. + return { + clientId, + name, + isValid: true, + attributes: sanitizedAttributes, + innerBlocks, + }; +} + +/** + * Given a block object, returns a copy of the block object, optionally merging + * new attributes and/or replacing its inner blocks. + * + * @param {Object} block Block instance. + * @param {Object} mergeAttributes Block attributes. + * @param {?Array} newInnerBlocks Nested blocks. + * + * @return {Object} A cloned block. + */ +export function cloneBlock( block, mergeAttributes = {}, newInnerBlocks ) { + const clientId = uuid(); + + return { + ...block, + clientId, + attributes: { + ...block.attributes, + ...mergeAttributes, + }, + innerBlocks: newInnerBlocks || + block.innerBlocks.map( ( innerBlock ) => cloneBlock( innerBlock ) ), + }; +} + +/** + * Returns a boolean indicating whether a transform is possible based on + * various bits of context. + * + * @param {Object} transform The transform object to validate. + * @param {string} direction Is this a 'from' or 'to' transform. + * @param {Array} blocks The blocks to transform from. + * + * @return {boolean} Is the transform possible? + */ +const isPossibleTransformForSource = ( transform, direction, blocks ) => { + if ( isEmpty( blocks ) ) { + return false; + } + + // If multiple blocks are selected, only multi block transforms + // or wildcard transforms are allowed. + const isMultiBlock = blocks.length > 1; + const firstBlockName = first( blocks ).name; + const isValidForMultiBlocks = isWildcardBlockTransform( transform ) || ! isMultiBlock || transform.isMultiBlock; + if ( ! isValidForMultiBlocks ) { + return false; + } + + // Check non-wildcard transforms to ensure that transform is valid + // for a block selection of multiple blocks of different types + if ( ! isWildcardBlockTransform( transform ) && ! every( blocks, { name: firstBlockName } ) ) { + return false; + } + + // Only consider 'block' type transforms as valid. + const isBlockType = transform.type === 'block'; + if ( ! isBlockType ) { + return false; + } + + // Check if the transform's block name matches the source block (or is a wildcard) + // only if this is a transform 'from'. + const sourceBlock = first( blocks ); + const hasMatchingName = direction !== 'from' || transform.blocks.indexOf( sourceBlock.name ) !== -1 || isWildcardBlockTransform( transform ); + if ( ! hasMatchingName ) { + return false; + } + + // Don't allow single Grouping blocks to be transformed into + // a Grouping block. + if ( ! isMultiBlock && isContainerGroupBlock( sourceBlock.name ) && isContainerGroupBlock( transform.blockName ) ) { + return false; + } + + // If the transform has a `isMatch` function specified, check that it returns true. + if ( isFunction( transform.isMatch ) ) { + const attributes = transform.isMultiBlock ? blocks.map( ( block ) => block.attributes ) : sourceBlock.attributes; + if ( ! transform.isMatch( attributes ) ) { + return false; + } + } + + return true; +}; + +/** + * Returns block types that the 'blocks' can be transformed into, based on + * 'from' transforms on other blocks. + * + * @param {Array} blocks The blocks to transform from. + * + * @return {Array} Block types that the blocks can be transformed into. + */ +const getBlockTypesForPossibleFromTransforms = ( blocks ) => { + if ( isEmpty( blocks ) ) { + return []; + } + + const allBlockTypes = getBlockTypes(); + + // filter all blocks to find those with a 'from' transform. + const blockTypesWithPossibleFromTransforms = filter( + allBlockTypes, + ( blockType ) => { + const fromTransforms = getBlockTransforms( 'from', blockType.name ); + + return !! findTransform( + fromTransforms, + ( transform ) => { + return isPossibleTransformForSource( transform, 'from', blocks ); + } + ); + }, + ); + + return blockTypesWithPossibleFromTransforms; +}; + +/** + * Returns block types that the 'blocks' can be transformed into, based on + * the source block's own 'to' transforms. + * + * @param {Array} blocks The blocks to transform from. + * + * @return {Array} Block types that the source can be transformed into. + */ +const getBlockTypesForPossibleToTransforms = ( blocks ) => { + if ( isEmpty( blocks ) ) { + return []; + } + + const sourceBlock = first( blocks ); + const blockType = getBlockType( sourceBlock.name ); + const transformsTo = getBlockTransforms( 'to', blockType.name ); + + // filter all 'to' transforms to find those that are possible. + const possibleTransforms = filter( + transformsTo, + ( transform ) => { + return transform && isPossibleTransformForSource( transform, 'to', blocks ); + } + ); + + // Build a list of block names using the possible 'to' transforms. + const blockNames = flatMap( + possibleTransforms, + ( transformation ) => transformation.blocks + ); + + // Map block names to block types. + return blockNames.map( ( name ) => getBlockType( name ) ); +}; + +/** + * Determines whether transform is a "block" type + * and if so whether it is a "wildcard" transform + * ie: targets "any" block type + * + * @param {Object} t the Block transform object + * + * @return {boolean} whether transform is a wildcard transform + */ +export const isWildcardBlockTransform = ( t ) => t && t.type === 'block' && Array.isArray( t.blocks ) && t.blocks.includes( '*' ); + +/** + * Determines whether the given Block is the core Block which + * acts as a container Block for other Blocks as part of the + * Grouping mechanics + * + * @param {string} name the name of the Block to test against + * + * @return {boolean} whether or not the Block is the container Block type + */ +export const isContainerGroupBlock = ( name ) => name === getGroupingBlockName(); + +/** + * Determines whether the provided Blocks are of the same type + * (eg: all `core/paragraph`). + * + * @param {Array} blocksArray the Block definitions + * + * @return {boolean} whether or not the given Blocks pass the criteria + */ +export const isBlockSelectionOfSameType = ( blocksArray = [] ) => { + if ( ! blocksArray.length ) { + return false; + } + const sourceName = blocksArray[ 0 ].name; + + return every( blocksArray, [ 'name', sourceName ] ); +}; + +/** + * Returns an array of block types that the set of blocks received as argument + * can be transformed into. + * + * @param {Array} blocks Blocks array. + * + * @return {Array} Block types that the blocks argument can be transformed to. + */ +export function getPossibleBlockTransformations( blocks ) { + if ( isEmpty( blocks ) ) { + return []; + } + + const blockTypesForFromTransforms = getBlockTypesForPossibleFromTransforms( blocks ); + const blockTypesForToTransforms = getBlockTypesForPossibleToTransforms( blocks ); + + return uniq( [ + ...blockTypesForFromTransforms, + ...blockTypesForToTransforms, + ] ); +} + +/** + * Given an array of transforms, returns the highest-priority transform where + * the predicate function returns a truthy value. A higher-priority transform + * is one with a lower priority value (i.e. first in priority order). Returns + * null if the transforms set is empty or the predicate function returns a + * falsey value for all entries. + * + * @param {Object[]} transforms Transforms to search. + * @param {Function} predicate Function returning true on matching transform. + * + * @return {?Object} Highest-priority transform candidate. + */ +export function findTransform( transforms, predicate ) { + // The hooks library already has built-in mechanisms for managing priority + // queue, so leverage via locally-defined instance. + const hooks = createHooks(); + + for ( let i = 0; i < transforms.length; i++ ) { + const candidate = transforms[ i ]; + if ( predicate( candidate ) ) { + hooks.addFilter( + 'transform', + 'transform/' + i.toString(), + ( result ) => result ? result : candidate, + candidate.priority + ); + } + } + + // Filter name is arbitrarily chosen but consistent with above aggregation. + return hooks.applyFilters( 'transform', null ); +} + +/** + * Returns normal block transforms for a given transform direction, optionally + * for a specific block by name, or an empty array if there are no transforms. + * If no block name is provided, returns transforms for all blocks. A normal + * transform object includes `blockName` as a property. + * + * @param {string} direction Transform direction ("to", "from"). + * @param {string|Object} blockTypeOrName Block type or name. + * + * @return {Array} Block transforms for direction. + */ +export function getBlockTransforms( direction, blockTypeOrName ) { + // When retrieving transforms for all block types, recurse into self. + if ( blockTypeOrName === undefined ) { + return flatMap( + getBlockTypes(), + ( { name } ) => getBlockTransforms( direction, name ) + ); + } + + // Validate that block type exists and has array of direction. + const blockType = normalizeBlockType( blockTypeOrName ); + const { name: blockName, transforms } = blockType || {}; + if ( ! transforms || ! Array.isArray( transforms[ direction ] ) ) { + return []; + } + + // Map transforms to normal form. + return transforms[ direction ].map( ( transform ) => ( { + ...transform, + blockName, + } ) ); +} + +/** + * Switch one or more blocks into one or more blocks of the new block type. + * + * @param {Array|Object} blocks Blocks array or block object. + * @param {string} name Block name. + * + * @return {?Array} Array of blocks or null. + */ +export function switchToBlockType( blocks, name ) { + const blocksArray = castArray( blocks ); + const isMultiBlock = blocksArray.length > 1; + const firstBlock = blocksArray[ 0 ]; + const sourceName = firstBlock.name; + + // Unless it's a Grouping Block then for multi block selections + // check that all Blocks are of the same type otherwise + // we can't run a conversion + if ( ! isContainerGroupBlock( name ) && isMultiBlock && ! isBlockSelectionOfSameType( blocksArray ) ) { + return null; + } + + // Find the right transformation by giving priority to the "to" + // transformation. + const transformationsFrom = getBlockTransforms( 'from', name ); + const transformationsTo = getBlockTransforms( 'to', sourceName ); + + const transformation = + findTransform( + transformationsTo, + ( t ) => t.type === 'block' && ( ( isWildcardBlockTransform( t ) ) || t.blocks.indexOf( name ) !== -1 ) && ( ! isMultiBlock || t.isMultiBlock ) + ) || + findTransform( + transformationsFrom, + ( t ) => t.type === 'block' && ( ( isWildcardBlockTransform( t ) ) || t.blocks.indexOf( sourceName ) !== -1 ) && ( ! isMultiBlock || t.isMultiBlock ) + ); + + // Stop if there is no valid transformation. + if ( ! transformation ) { + return null; + } + + let transformationResults; + + if ( transformation.isMultiBlock ) { + if ( has( transformation, '__experimentalConvert' ) ) { + transformationResults = transformation.__experimentalConvert( blocksArray ); + } else { + transformationResults = transformation.transform( + blocksArray.map( ( currentBlock ) => currentBlock.attributes ), + blocksArray.map( ( currentBlock ) => currentBlock.innerBlocks ), + ); + } + } else if ( has( transformation, '__experimentalConvert' ) ) { + transformationResults = transformation.__experimentalConvert( firstBlock ); + } else { + transformationResults = transformation.transform( firstBlock.attributes, firstBlock.innerBlocks ); + } + + // Ensure that the transformation function returned an object or an array + // of objects. + if ( ! isObjectLike( transformationResults ) ) { + return null; + } + + // If the transformation function returned a single object, we want to work + // with an array instead. + transformationResults = castArray( transformationResults ); + + // Ensure that every block object returned by the transformation has a + // valid block type. + if ( transformationResults.some( ( result ) => ! getBlockType( result.name ) ) ) { + return null; + } + + const firstSwitchedBlock = findIndex( transformationResults, ( result ) => result.name === name ); + + // Ensure that at least one block object returned by the transformation has + // the expected "destination" block type. + if ( firstSwitchedBlock < 0 ) { + return null; + } + + return transformationResults.map( ( result, index ) => { + const transformedBlock = { + ...result, + // The first transformed block whose type matches the "destination" + // type gets to keep the existing client ID of the first block. + clientId: index === firstSwitchedBlock ? firstBlock.clientId : result.clientId, + }; + + /** + * Filters an individual transform result from block transformation. + * All of the original blocks are passed, since transformations are + * many-to-many, not one-to-one. + * + * @param {Object} transformedBlock The transformed block. + * @param {Object[]} blocks Original blocks transformed. + */ + return applyFilters( 'blocks.switchToBlockType.transformedBlock', transformedBlock, blocks ); + } ); +} + +/** + * Create a block object from the example API. + * + * @param {string} name + * @param {Object} example + * + * @return {Object} block. + */ +export const getBlockFromExample = ( name, example ) => { + return createBlock( name, example.attributes, map( + example.innerBlocks, ( innerBlock ) => getBlockFromExample( innerBlock.name, innerBlock ) + ) ); +}; diff --git a/gb-src/packages/blocks/src/api/index.js b/gb-src/packages/blocks/src/api/index.js new file mode 100644 index 0000000000000..62074213559e6 --- /dev/null +++ b/gb-src/packages/blocks/src/api/index.js @@ -0,0 +1,63 @@ +export { + createBlock, + cloneBlock, + getPossibleBlockTransformations, + switchToBlockType, + getBlockTransforms, + findTransform, + getBlockFromExample, +} from './factory'; +export { + default as parse, + getBlockAttributes, + parseWithAttributeSchema, +} from './parser'; +export { pasteHandler, rawHandler, getPhrasingContentSchema } from './raw-handling'; +export { + default as serialize, + getBlockContent, + getBlockDefaultClassName, + getBlockMenuDefaultClassName, + getSaveElement, + getSaveContent, +} from './serializer'; +export { isValidBlockContent } from './validation'; +export { + getCategories, + setCategories, + updateCategory, +} from './categories'; +export { + registerBlockType, + unregisterBlockType, + setFreeformContentHandlerName, + getFreeformContentHandlerName, + setUnregisteredTypeHandlerName, + getUnregisteredTypeHandlerName, + setDefaultBlockName, + getDefaultBlockName, + setGroupingBlockName, + getGroupingBlockName, + getBlockType, + getBlockTypes, + getBlockSupport, + hasBlockSupport, + isReusableBlock, + getChildBlockNames, + hasChildBlocks, + hasChildBlocksWithInserterSupport, + unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase + registerBlockStyle, + unregisterBlockStyle, +} from './registration'; +export { + isUnmodifiedDefaultBlock, + normalizeIconObject, + isValidIcon, +} from './utils'; +export { + doBlocksMatchTemplate, + synchronizeBlocksWithTemplate, +} from './templates'; +export { default as children } from './children'; +export { default as node } from './node'; diff --git a/gb-src/packages/blocks/src/api/index.native.js b/gb-src/packages/blocks/src/api/index.native.js new file mode 100644 index 0000000000000..3b3be8f28c3a4 --- /dev/null +++ b/gb-src/packages/blocks/src/api/index.native.js @@ -0,0 +1,39 @@ +export { + cloneBlock, + createBlock, + switchToBlockType, +} from './factory'; +export { + default as parse, + getBlockAttributes, + parseWithAttributeSchema, +} from './parser'; +export { + default as serialize, + getBlockContent, + getBlockDefaultClassName, + getSaveContent, +} from './serializer'; +export { + registerBlockType, + unregisterBlockType, + getFreeformContentHandlerName, + setUnregisteredTypeHandlerName, + getUnregisteredTypeHandlerName, + getBlockType, + getBlockTypes, + getBlockSupport, + hasBlockSupport, + isReusableBlock, + getChildBlockNames, + hasChildBlocks, + hasChildBlocksWithInserterSupport, + setDefaultBlockName, + getDefaultBlockName, +} from './registration'; +export { + isUnmodifiedDefaultBlock, + normalizeIconObject, +} from './utils'; +export { pasteHandler, getPhrasingContentSchema } from './raw-handling'; +export { default as children } from './children'; diff --git a/gb-src/packages/blocks/src/api/matchers.js b/gb-src/packages/blocks/src/api/matchers.js new file mode 100644 index 0000000000000..7a6ac84891658 --- /dev/null +++ b/gb-src/packages/blocks/src/api/matchers.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +export { attr, prop, text, query } from 'hpq'; + +/** + * Internal dependencies + */ +export { matcher as node } from './node'; +export { matcher as children } from './children'; + +export function html( selector, multilineTag ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + if ( ! match ) { + return ''; + } + + if ( multilineTag ) { + let value = ''; + const length = match.children.length; + + for ( let index = 0; index < length; index++ ) { + const child = match.children[ index ]; + + if ( child.nodeName.toLowerCase() !== multilineTag ) { + continue; + } + + value += child.outerHTML; + } + + return value; + } + + return match.innerHTML; + }; +} diff --git a/gb-src/packages/blocks/src/api/node.js b/gb-src/packages/blocks/src/api/node.js new file mode 100644 index 0000000000000..075715f1d84c4 --- /dev/null +++ b/gb-src/packages/blocks/src/api/node.js @@ -0,0 +1,137 @@ +/** + * Internal dependencies + */ +import * as children from './children'; + +/** + * Browser dependencies + */ +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * A representation of a single node within a block's rich text value. If + * representing a text node, the value is simply a string of the node value. + * As representing an element node, it is an object of: + * + * 1. `type` (string): Tag name. + * 2. `props` (object): Attributes and children array of WPBlockNode. + * + * @typedef {string|Object} WPBlockNode + */ + +/** + * Given a single node and a node type (e.g. `'br'`), returns true if the node + * corresponds to that type, false otherwise. + * + * @param {WPBlockNode} node Block node to test + * @param {string} type Node to type to test against. + * + * @return {boolean} Whether node is of intended type. + */ +function isNodeOfType( node, type ) { + return node && node.type === type; +} + +/** + * Given an object implementing the NamedNodeMap interface, returns a plain + * object equivalent value of name, value key-value pairs. + * + * @see https://dom.spec.whatwg.org/#interface-namednodemap + * + * @param {NamedNodeMap} nodeMap NamedNodeMap to convert to object. + * + * @return {Object} Object equivalent value of NamedNodeMap. + */ +export function getNamedNodeMapAsObject( nodeMap ) { + const result = {}; + for ( let i = 0; i < nodeMap.length; i++ ) { + const { name, value } = nodeMap[ i ]; + result[ name ] = value; + } + + return result; +} + +/** + * Given a DOM Element or Text node, returns an equivalent block node. Throws + * if passed any node type other than element or text. + * + * @throws {TypeError} If non-element/text node is passed. + * + * @param {Node} domNode DOM node to convert. + * + * @return {WPBlockNode} Block node equivalent to DOM node. + */ +export function fromDOM( domNode ) { + if ( domNode.nodeType === TEXT_NODE ) { + return domNode.nodeValue; + } + + if ( domNode.nodeType !== ELEMENT_NODE ) { + throw new TypeError( + 'A block node can only be created from a node of type text or ' + + 'element.' + ); + } + + return { + type: domNode.nodeName.toLowerCase(), + props: { + ...getNamedNodeMapAsObject( domNode.attributes ), + children: children.fromDOM( domNode.childNodes ), + }, + }; +} + +/** + * Given a block node, returns its HTML string representation. + * + * @param {WPBlockNode} node Block node to convert to string. + * + * @return {string} String HTML representation of block node. + */ +export function toHTML( node ) { + return children.toHTML( [ node ] ); +} + +/** + * Given a selector, returns an hpq matcher generating a WPBlockNode value + * matching the selector result. + * + * @param {string} selector DOM selector. + * + * @return {Function} hpq matcher. + */ +export function matcher( selector ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + try { + return fromDOM( match ); + } catch ( error ) { + return null; + } + }; +} + +/** + * Object of utility functions used in managing block attribute values of + * source `node`. + * + * @see https://github.com/WordPress/gutenberg/pull/10439 + * + * @deprecated since 4.0. The `node` source should not be used, and can be + * replaced by the `html` source. + * + * @private + */ +export default { + isNodeOfType, + fromDOM, + toHTML, + matcher, +}; diff --git a/gb-src/packages/blocks/src/api/parser.js b/gb-src/packages/blocks/src/api/parser.js new file mode 100644 index 0000000000000..89b4f5ee5cc9f --- /dev/null +++ b/gb-src/packages/blocks/src/api/parser.js @@ -0,0 +1,585 @@ +/** + * External dependencies + */ +import { parse as hpqParse } from 'hpq'; +import { flow, get, castArray, mapValues, omit, stubFalse } from 'lodash'; + +/** + * WordPress dependencies + */ +import { autop } from '@wordpress/autop'; +import { applyFilters } from '@wordpress/hooks'; +import { parse as defaultParse } from '@wordpress/block-serialization-default-parser'; + +/** + * Internal dependencies + */ +import { + getBlockType, + getFreeformContentHandlerName, + getUnregisteredTypeHandlerName, +} from './registration'; +import { createBlock } from './factory'; +import { getBlockContentValidationResult } from './validation'; +import { getCommentDelimitedContent, getSaveContent } from './serializer'; +import { attr, html, text, query, node, children, prop } from './matchers'; +import { normalizeBlockType } from './utils'; +import { DEPRECATED_ENTRY_KEYS } from './constants'; + +/** + * Sources which are guaranteed to return a string value. + * + * @type {Set} + */ +const STRING_SOURCES = new Set( [ + 'attribute', + 'html', + 'text', + 'tag', +] ); + +/** + * Higher-order hpq matcher which enhances an attribute matcher to return true + * or false depending on whether the original matcher returns undefined. This + * is useful for boolean attributes (e.g. disabled) whose attribute values may + * be technically falsey (empty string), though their mere presence should be + * enough to infer as true. + * + * @param {Function} matcher Original hpq matcher. + * + * @return {Function} Enhanced hpq matcher. + */ +export const toBooleanAttributeMatcher = ( matcher ) => flow( [ + matcher, + // Expected values from `attr( 'disabled' )`: + // + // <input> + // - Value: `undefined` + // - Transformed: `false` + // + // <input disabled> + // - Value: `''` + // - Transformed: `true` + // + // <input disabled="disabled"> + // - Value: `'disabled'` + // - Transformed: `true` + ( value ) => value !== undefined, +] ); + +/** + * Returns true if value is of the given JSON schema type, or false otherwise. + * + * @see http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25 + * + * @param {*} value Value to test. + * @param {string} type Type to test. + * + * @return {boolean} Whether value is of type. + */ +export function isOfType( value, type ) { + switch ( type ) { + case 'string': + return typeof value === 'string'; + + case 'boolean': + return typeof value === 'boolean'; + + case 'object': + return !! value && value.constructor === Object; + + case 'null': + return value === null; + + case 'array': + return Array.isArray( value ); + + case 'integer': + case 'number': + return typeof value === 'number'; + } + + return true; +} + +/** + * Returns true if value is of an array of given JSON schema types, or false + * otherwise. + * + * @see http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25 + * + * @param {*} value Value to test. + * @param {string[]} types Types to test. + * + * @return {boolean} Whether value is of types. + */ +export function isOfTypes( value, types ) { + return types.some( ( type ) => isOfType( value, type ) ); +} + +/** + * Returns true if value is valid per the given block attribute schema type + * definition, or false otherwise. + * + * @see https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.1 + * + * @param {*} value Value to test. + * @param {?(Array<string>|string)} type Block attribute schema type. + * + * @return {boolean} Whether value is valid. + */ +export function isValidByType( value, type ) { + return type === undefined || isOfTypes( value, castArray( type ) ); +} + +/** + * Returns true if value is valid per the given block attribute schema enum + * definition, or false otherwise. + * + * @see https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.2 + * + * @param {*} value Value to test. + * @param {?Array} enumSet Block attribute schema enum. + * + * @return {boolean} Whether value is valid. + */ +export function isValidByEnum( value, enumSet ) { + return ! Array.isArray( enumSet ) || enumSet.includes( value ); +} + +/** + * Returns true if the given attribute schema describes a value which may be + * an ambiguous string. + * + * Some sources are ambiguously serialized as strings, for which value casting + * is enabled. This is only possible when a singular type is assigned to the + * attribute schema, since the string ambiguity makes it impossible to know the + * correct type of multiple to which to cast. + * + * @param {Object} attributeSchema Attribute's schema. + * + * @return {boolean} Whether attribute schema defines an ambiguous string + * source. + */ +export function isAmbiguousStringSource( attributeSchema ) { + const { source, type } = attributeSchema; + + const isStringSource = STRING_SOURCES.has( source ); + const isSingleType = typeof type === 'string'; + + return isStringSource && isSingleType; +} + +/** + * Returns an hpq matcher given a source object. + * + * @param {Object} sourceConfig Attribute Source object. + * + * @return {Function} A hpq Matcher. + */ +export function matcherFromSource( sourceConfig ) { + switch ( sourceConfig.source ) { + case 'attribute': + let matcher = attr( sourceConfig.selector, sourceConfig.attribute ); + if ( sourceConfig.type === 'boolean' ) { + matcher = toBooleanAttributeMatcher( matcher ); + } + + return matcher; + case 'html': + return html( sourceConfig.selector, sourceConfig.multiline ); + case 'text': + return text( sourceConfig.selector ); + case 'children': + return children( sourceConfig.selector ); + case 'node': + return node( sourceConfig.selector ); + case 'query': + const subMatchers = mapValues( sourceConfig.query, matcherFromSource ); + return query( sourceConfig.selector, subMatchers ); + case 'tag': + return flow( [ + prop( sourceConfig.selector, 'nodeName' ), + ( nodeName ) => nodeName ? nodeName.toLowerCase() : undefined, + ] ); + default: + // eslint-disable-next-line no-console + console.error( `Unknown source type "${ sourceConfig.source }"` ); + } +} + +/** + * Given a block's raw content and an attribute's schema returns the attribute's + * value depending on its source. + * + * @param {string} innerHTML Block's raw content. + * @param {Object} attributeSchema Attribute's schema. + * + * @return {*} Attribute value. + */ +export function parseWithAttributeSchema( innerHTML, attributeSchema ) { + return hpqParse( innerHTML, matcherFromSource( attributeSchema ) ); +} + +/** + * Given an attribute key, an attribute's schema, a block's raw content and the + * commentAttributes returns the attribute value depending on its source + * definition of the given attribute key. + * + * @param {string} attributeKey Attribute key. + * @param {Object} attributeSchema Attribute's schema. + * @param {string} innerHTML Block's raw content. + * @param {Object} commentAttributes Block's comment attributes. + * + * @return {*} Attribute value. + */ +export function getBlockAttribute( attributeKey, attributeSchema, innerHTML, commentAttributes ) { + const { type, enum: enumSet } = attributeSchema; + let value; + + switch ( attributeSchema.source ) { + // undefined source means that it's an attribute serialized to the block's "comment" + case undefined: + value = commentAttributes ? commentAttributes[ attributeKey ] : undefined; + break; + case 'attribute': + case 'property': + case 'html': + case 'text': + case 'children': + case 'node': + case 'query': + case 'tag': + value = parseWithAttributeSchema( innerHTML, attributeSchema ); + break; + } + + if ( ! isValidByType( value, type ) || ! isValidByEnum( value, enumSet ) ) { + // Reject the value if it is not valid. Reverting to the undefined + // value ensures the default is respected, if applicable. + value = undefined; + } + + if ( value === undefined ) { + return attributeSchema.default; + } + + return value; +} + +/** + * Returns the block attributes of a registered block node given its type. + * + * @param {string|Object} blockTypeOrName Block type or name. + * @param {string} innerHTML Raw block content. + * @param {?Object} attributes Known block attributes (from delimiters). + * + * @return {Object} All block attributes. + */ +export function getBlockAttributes( blockTypeOrName, innerHTML, attributes = {} ) { + const blockType = normalizeBlockType( blockTypeOrName ); + const blockAttributes = mapValues( blockType.attributes, ( attributeSchema, attributeKey ) => { + return getBlockAttribute( attributeKey, attributeSchema, innerHTML, attributes ); + } ); + + return applyFilters( + 'blocks.getBlockAttributes', + blockAttributes, + blockType, + innerHTML, + attributes + ); +} + +/** + * Given a block object, returns a new copy of the block with any applicable + * deprecated migrations applied, or the original block if it was both valid + * and no eligible migrations exist. + * + * @param {WPBlock} block Original block object. + * @param {Object} parsedAttributes Attributes as parsed from the initial + * block markup. + * + * @return {WPBlock} Migrated block object. + */ +export function getMigratedBlock( block, parsedAttributes ) { + const blockType = getBlockType( block.name ); + + const { deprecated: deprecatedDefinitions } = blockType; + if ( ! deprecatedDefinitions || ! deprecatedDefinitions.length ) { + return block; + } + + const { originalContent, innerBlocks } = block; + + for ( let i = 0; i < deprecatedDefinitions.length; i++ ) { + // A block can opt into a migration even if the block is valid by + // defining isEligible on its deprecation. If the block is both valid + // and does not opt to migrate, skip. + const { isEligible = stubFalse } = deprecatedDefinitions[ i ]; + if ( block.isValid && ! isEligible( parsedAttributes, innerBlocks ) ) { + continue; + } + + // Block type properties which could impact either serialization or + // parsing are not considered in the deprecated block type by default, + // and must be explicitly provided. + const deprecatedBlockType = Object.assign( + omit( blockType, DEPRECATED_ENTRY_KEYS ), + deprecatedDefinitions[ i ] + ); + + let migratedAttributes = getBlockAttributes( + deprecatedBlockType, + originalContent, + parsedAttributes + ); + + // Ignore the deprecation if it produces a block which is not valid. + const { isValid, validationIssues } = getBlockContentValidationResult( + deprecatedBlockType, + migratedAttributes, + originalContent + ); + + if ( ! isValid ) { + block = { + ...block, + validationIssues: [ + ...get( block, 'validationIssues', [] ), + ...validationIssues, + ], + }; + continue; + } + + let migratedInnerBlocks = innerBlocks; + + // A block may provide custom behavior to assign new attributes and/or + // inner blocks. + const { migrate } = deprecatedBlockType; + if ( migrate ) { + ( [ + migratedAttributes = parsedAttributes, + migratedInnerBlocks = innerBlocks, + ] = castArray( migrate( migratedAttributes, innerBlocks ) ) ); + } + + block = { + ...block, + attributes: migratedAttributes, + innerBlocks: migratedInnerBlocks, + isValid: true, + }; + } + + return block; +} + +/** + * Creates a block with fallback to the unknown type handler. + * + * @param {Object} blockNode Parsed block node. + * + * @return {?Object} An initialized block object (if possible). + */ +export function createBlockWithFallback( blockNode ) { + const { blockName: originalName } = blockNode; + let { + attrs: attributes, + innerBlocks = [], + innerHTML, + } = blockNode; + const { innerContent } = blockNode; + const freeformContentFallbackBlock = getFreeformContentHandlerName(); + const unregisteredFallbackBlock = getUnregisteredTypeHandlerName() || freeformContentFallbackBlock; + + attributes = attributes || {}; + + // Trim content to avoid creation of intermediary freeform segments. + innerHTML = innerHTML.trim(); + + // Use type from block content if available. Otherwise, default to the + // freeform content fallback. + let name = originalName || freeformContentFallbackBlock; + + // Convert 'core/cover-image' block in existing content to 'core/cover'. + if ( 'core/cover-image' === name ) { + name = 'core/cover'; + } + + // Convert 'core/text' blocks in existing content to 'core/paragraph'. + if ( 'core/text' === name || 'core/cover-text' === name ) { + name = 'core/paragraph'; + } + + // Fallback content may be upgraded from classic editor expecting implicit + // automatic paragraphs, so preserve them. Assumes wpautop is idempotent, + // meaning there are no negative consequences to repeated autop calls. + if ( name === freeformContentFallbackBlock ) { + innerHTML = autop( innerHTML ).trim(); + } + + // Try finding the type for known block name, else fall back again. + let blockType = getBlockType( name ); + + if ( ! blockType ) { + // Since the constituents of the block node are extracted at the start + // of the present function, construct a new object rather than reuse + // `blockNode`. + const reconstitutedBlockNode = { + attrs: attributes, + blockName: originalName, + innerBlocks, + innerContent, + }; + + // Preserve undelimited content for use by the unregistered type + // handler. A block node's `innerHTML` isn't enough, as that field only + // carries the block's own HTML and not its nested blocks'. + const originalUndelimitedContent = serializeBlockNode( + reconstitutedBlockNode, + { isCommentDelimited: false } + ); + + // Preserve full block content for use by the unregistered type + // handler, block boundaries included. + const originalContent = serializeBlockNode( + reconstitutedBlockNode, + { isCommentDelimited: true } + ); + + // If detected as a block which is not registered, preserve comment + // delimiters in content of unregistered type handler. + if ( name ) { + innerHTML = originalContent; + } + + name = unregisteredFallbackBlock; + attributes = { originalName, originalContent, originalUndelimitedContent }; + blockType = getBlockType( name ); + } + + // Coerce inner blocks from parsed form to canonical form. + innerBlocks = innerBlocks.map( createBlockWithFallback ); + + // Remove `undefined` innerBlocks. + // + // This is a temporary fix to prevent unrecoverable TypeErrors when handling unexpectedly + // empty freeform block nodes. See https://github.com/WordPress/gutenberg/pull/17164. + innerBlocks = innerBlocks.filter( ( innerBlock ) => innerBlock ); + + const isFallbackBlock = ( + name === freeformContentFallbackBlock || + name === unregisteredFallbackBlock + ); + + // Include in set only if type was determined. + if ( ! blockType || ( ! innerHTML && isFallbackBlock ) ) { + return; + } + + let block = createBlock( + name, + getBlockAttributes( blockType, innerHTML, attributes ), + innerBlocks + ); + + // Block validation assumes an idempotent operation from source block to serialized block + // provided there are no changes in attributes. The validation procedure thus compares the + // provided source value with the serialized output before there are any modifications to + // the block. When both match, the block is marked as valid. + if ( ! isFallbackBlock ) { + const { isValid, validationIssues } = getBlockContentValidationResult( blockType, block.attributes, innerHTML ); + block.isValid = isValid; + block.validationIssues = validationIssues; + } + + // Preserve original content for future use in case the block is parsed + // as invalid, or future serialization attempt results in an error. + block.originalContent = block.originalContent || innerHTML; + + block = getMigratedBlock( block, attributes ); + + if ( block.validationIssues && block.validationIssues.length > 0 ) { + if ( block.isValid ) { + // eslint-disable-next-line no-console + console.info( + 'Block successfully updated for `%s` (%o).\n\nNew content generated by `save` function:\n\n%s\n\nContent retrieved from post body:\n\n%s', + blockType.name, + blockType, + getSaveContent( blockType, block.attributes ), + block.originalContent + ); + } else { + block.validationIssues.forEach( ( { log, args } ) => log( ...args ) ); + } + } + + return block; +} + +/** + * Serializes a block node into the native HTML-comment-powered block format. + * CAVEAT: This function is intended for reserializing blocks as parsed by + * valid parsers and skips any validation steps. This is NOT a generic + * serialization function for in-memory blocks. For most purposes, see the + * following functions available in the `@wordpress/blocks` package: + * + * @see serializeBlock + * @see serialize + * + * For more on the format of block nodes as returned by valid parsers: + * + * @see `@wordpress/block-serialization-default-parser` package + * @see `@wordpress/block-serialization-spec-parser` package + * + * @param {Object} blockNode A block node as returned by a valid parser. + * @param {?Object} options Serialization options. + * @param {?boolean} options.isCommentDelimited Whether to output HTML comments around blocks. + * + * @return {string} An HTML string representing a block. + */ +export function serializeBlockNode( blockNode, options = {} ) { + const { isCommentDelimited = true } = options; + const { blockName, attrs = {}, innerBlocks = [], innerContent = [] } = blockNode; + + let childIndex = 0; + const content = innerContent.map( ( item ) => + // `null` denotes a nested block, otherwise we have an HTML fragment + item !== null ? + item : + serializeBlockNode( innerBlocks[ childIndex++ ], options ) + ).join( '\n' ).replace( /\n+/g, '\n' ).trim(); + + return isCommentDelimited ? + getCommentDelimitedContent( blockName, attrs, content ) : + content; +} + +/** + * Creates a parse implementation for the post content which returns a list of blocks. + * + * @param {Function} parseImplementation Parse implementation. + * + * @return {Function} An implementation which parses the post content. + */ +const createParse = ( parseImplementation ) => + ( content ) => parseImplementation( content ).reduce( ( memo, blockNode ) => { + const block = createBlockWithFallback( blockNode ); + if ( block ) { + memo.push( block ); + } + return memo; + }, [] ); + +/** + * Parses the post content with a PegJS grammar and returns a list of blocks. + * + * @param {string} content The post content. + * + * @return {Array} Block list. + */ +export const parseWithGrammar = createParse( defaultParse ); + +export default parseWithGrammar; diff --git a/gb-src/packages/blocks/src/api/raw-handling/blockquote-normaliser.js b/gb-src/packages/blocks/src/api/raw-handling/blockquote-normaliser.js new file mode 100644 index 0000000000000..f08e89791280b --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/blockquote-normaliser.js @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import normaliseBlocks from './normalise-blocks'; + +export default function( node ) { + if ( node.nodeName !== 'BLOCKQUOTE' ) { + return; + } + + node.innerHTML = normaliseBlocks( node.innerHTML ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/comment-remover.js b/gb-src/packages/blocks/src/api/raw-handling/comment-remover.js new file mode 100644 index 0000000000000..fbfa319ccb831 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/comment-remover.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { remove } from '@wordpress/dom'; + +/** + * Browser dependencies + */ +const { COMMENT_NODE } = window.Node; + +/** + * Looks for comments, and removes them. + * + * @param {Node} node The node to be processed. + * @return {void} + */ +export default function( node ) { + if ( node.nodeType === COMMENT_NODE ) { + remove( node ); + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/figure-content-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/figure-content-reducer.js new file mode 100644 index 0000000000000..86478176fdb88 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/figure-content-reducer.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { has } from 'lodash'; + +/** + * Internal dependencies + */ +import { isPhrasingContent } from './phrasing-content'; + +/** + * Whether or not the given node is figure content. + * + * @param {Node} node The node to check. + * @param {Object} schema The schema to use. + * + * @return {boolean} True if figure content, false if not. + */ +function isFigureContent( node, schema ) { + const tag = node.nodeName.toLowerCase(); + + // We are looking for tags that can be a child of the figure tag, excluding + // `figcaption` and any phrasing content. + if ( tag === 'figcaption' || isPhrasingContent( node ) ) { + return false; + } + + return has( schema, [ 'figure', 'children', tag ] ); +} + +/** + * Whether or not the given node can have an anchor. + * + * @param {Node} node The node to check. + * @param {Object} schema The schema to use. + * + * @return {boolean} True if it can, false if not. + */ +function canHaveAnchor( node, schema ) { + const tag = node.nodeName.toLowerCase(); + + return has( schema, [ 'figure', 'children', 'a', 'children', tag ] ); +} + +/** + * This filter takes figure content out of paragraphs, wraps it in a figure + * element, and moves any anchors with it if needed. + * + * @param {Node} node The node to filter. + * @param {Document} doc The document of the node. + * @param {Object} schema The schema to use. + * + * @return {void} + */ +export default function( node, doc, schema ) { + if ( ! isFigureContent( node, schema ) ) { + return; + } + + let nodeToInsert = node; + const parentNode = node.parentNode; + + // If the figure content can have an anchor and its parent is an anchor with + // only the figure content, take the anchor out instead of just the content. + if ( + canHaveAnchor( node, schema ) && + parentNode.nodeName === 'A' && + parentNode.childNodes.length === 1 + ) { + nodeToInsert = node.parentNode; + } + + let wrapper = nodeToInsert; + + while ( wrapper && wrapper.nodeName !== 'P' ) { + wrapper = wrapper.parentElement; + } + + const figure = doc.createElement( 'figure' ); + + if ( wrapper ) { + wrapper.parentNode.insertBefore( figure, wrapper ); + } else { + nodeToInsert.parentNode.insertBefore( figure, nodeToInsert ); + } + + figure.appendChild( nodeToInsert ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/google-docs-uid-remover.js b/gb-src/packages/blocks/src/api/raw-handling/google-docs-uid-remover.js new file mode 100644 index 0000000000000..adced320975d7 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/google-docs-uid-remover.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { unwrap } from '@wordpress/dom'; + +export default function( node ) { + if ( ! node.id || node.id.indexOf( 'docs-internal-guid-' ) !== 0 ) { + return; + } + + unwrap( node ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/head-remover.js b/gb-src/packages/blocks/src/api/raw-handling/head-remover.js new file mode 100644 index 0000000000000..d3c96c9841b8c --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/head-remover.js @@ -0,0 +1,12 @@ +export default function( node ) { + if ( + node.nodeName !== 'SCRIPT' && + node.nodeName !== 'NOSCRIPT' && + node.nodeName !== 'TEMPLATE' && + node.nodeName !== 'STYLE' + ) { + return; + } + + node.parentNode.removeChild( node ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/iframe-remover.js b/gb-src/packages/blocks/src/api/raw-handling/iframe-remover.js new file mode 100644 index 0000000000000..35adb29cdf808 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/iframe-remover.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { remove } from '@wordpress/dom'; + +/** + * Removes iframes. + * + * @param {Node} node The node to check. + * + * @return {void} + */ +export default function( node ) { + if ( node.nodeName === 'IFRAME' ) { + remove( node ); + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/image-corrector.js b/gb-src/packages/blocks/src/api/raw-handling/image-corrector.js new file mode 100644 index 0000000000000..780b0adbfef91 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/image-corrector.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { createBlobURL } from '@wordpress/blob'; + +/** + * Browser dependencies + */ +const { atob, File } = window; + +export default function( node ) { + if ( node.nodeName !== 'IMG' ) { + return; + } + + if ( node.src.indexOf( 'file:' ) === 0 ) { + node.src = ''; + } + + // This piece cannot be tested outside a browser env. + if ( node.src.indexOf( 'data:' ) === 0 ) { + const [ properties, data ] = node.src.split( ',' ); + const [ type ] = properties.slice( 5 ).split( ';' ); + + if ( ! data || ! type ) { + node.src = ''; + return; + } + + let decoded; + + // Can throw DOMException! + try { + decoded = atob( data ); + } catch ( e ) { + node.src = ''; + return; + } + + const uint8Array = new Uint8Array( decoded.length ); + + for ( let i = 0; i < uint8Array.length; i++ ) { + uint8Array[ i ] = decoded.charCodeAt( i ); + } + + const name = type.replace( '/', '.' ); + const file = new File( [ uint8Array ], name, { type } ); + + node.src = createBlobURL( file ); + } + + // Remove trackers and hardly visible images. + if ( node.height === 1 || node.width === 1 ) { + node.parentNode.removeChild( node ); + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/image-corrector.native.js b/gb-src/packages/blocks/src/api/raw-handling/image-corrector.native.js new file mode 100644 index 0000000000000..f25f6d3c8bc90 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/image-corrector.native.js @@ -0,0 +1,18 @@ + +/** + * This method check for copy pasted img elements to see if they don't have suspicious attributes. + * + * @param {Node} node The node to check. + * + * @return {void} + */ +export default function( node ) { + if ( node.nodeName !== 'IMG' ) { + return; + } + + // Remove trackers and hardly visible images. + if ( node.height === 1 || node.width === 1 ) { + node.parentNode.removeChild( node ); + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/index.js b/gb-src/packages/blocks/src/api/raw-handling/index.js new file mode 100644 index 0000000000000..37908f96d7d76 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/index.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { flatMap, filter, compact } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock, getBlockTransforms, findTransform } from '../factory'; +import { getBlockAttributes, parseWithGrammar } from '../parser'; +import normaliseBlocks from './normalise-blocks'; +import specialCommentConverter from './special-comment-converter'; +import listReducer from './list-reducer'; +import blockquoteNormaliser from './blockquote-normaliser'; +import figureContentReducer from './figure-content-reducer'; +import shortcodeConverter from './shortcode-converter'; +import { + deepFilterHTML, + getBlockContentSchema, +} from './utils'; + +export { getPhrasingContentSchema } from './phrasing-content'; +export { pasteHandler } from './paste-handler'; + +function getRawTransformations() { + return filter( getBlockTransforms( 'from' ), { type: 'raw' } ) + .map( ( transform ) => { + return transform.isMatch ? transform : { + ...transform, + isMatch: ( node ) => transform.selector && node.matches( transform.selector ), + }; + } ); +} + +/** + * Converts HTML directly to blocks. Looks for a matching transform for each + * top-level tag. The HTML should be filtered to not have any text between + * top-level tags and formatted in a way that blocks can handle the HTML. + * + * @param {Object} $1 Named parameters. + * @param {string} $1.html HTML to convert. + * @param {Array} $1.rawTransforms Transforms that can be used. + * + * @return {Array} An array of blocks. + */ +function htmlToBlocks( { html, rawTransforms } ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = html; + + return Array.from( doc.body.children ).map( ( node ) => { + const rawTransform = findTransform( rawTransforms, ( { isMatch } ) => isMatch( node ) ); + + if ( ! rawTransform ) { + return createBlock( + // Should not be hardcoded. + 'core/html', + getBlockAttributes( + 'core/html', + node.outerHTML + ) + ); + } + + const { transform, blockName } = rawTransform; + + if ( transform ) { + return transform( node ); + } + + return createBlock( + blockName, + getBlockAttributes( + blockName, + node.outerHTML + ) + ); + } ); +} + +/** + * Converts an HTML string to known blocks. + * + * @param {Object} $1 + * @param {string} $1.HTML The HTML to convert. + * + * @return {Array} A list of blocks. + */ +export function rawHandler( { HTML = '' } ) { + // If we detect block delimiters, parse entirely as blocks. + if ( HTML.indexOf( '<!-- wp:' ) !== -1 ) { + return parseWithGrammar( HTML ); + } + + // An array of HTML strings and block objects. The blocks replace matched + // shortcodes. + const pieces = shortcodeConverter( HTML ); + const rawTransforms = getRawTransformations(); + const blockContentSchema = getBlockContentSchema( rawTransforms ); + + return compact( flatMap( pieces, ( piece ) => { + // Already a block from shortcode. + if ( typeof piece !== 'string' ) { + return piece; + } + + // These filters are essential for some blocks to be able to transform + // from raw HTML. These filters move around some content or add + // additional tags, they do not remove any content. + const filters = [ + // Needed to adjust invalid lists. + listReducer, + // Needed to create more and nextpage blocks. + specialCommentConverter, + // Needed to create media blocks. + figureContentReducer, + // Needed to create the quote block, which cannot handle text + // without wrapper paragraphs. + blockquoteNormaliser, + ]; + + piece = deepFilterHTML( piece, filters, blockContentSchema ); + piece = normaliseBlocks( piece ); + + return htmlToBlocks( { html: piece, rawTransforms } ); + } ) ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/index.native.js b/gb-src/packages/blocks/src/api/raw-handling/index.native.js new file mode 100644 index 0000000000000..f7e468e3fe363 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/index.native.js @@ -0,0 +1,2 @@ +export { getPhrasingContentSchema } from './phrasing-content'; +export { pasteHandler } from './paste-handler'; diff --git a/gb-src/packages/blocks/src/api/raw-handling/is-inline-content.js b/gb-src/packages/blocks/src/api/raw-handling/is-inline-content.js new file mode 100644 index 0000000000000..e0067a4550b0b --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/is-inline-content.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { difference } from 'lodash'; + +/** + * Internal dependencies + */ +import { isPhrasingContent } from './phrasing-content'; + +/** + * Checks if the given node should be considered inline content, optionally + * depending on a context tag. + * + * @param {Node} node Node name. + * @param {string} contextTag Tag name. + * + * @return {boolean} True if the node is inline content, false if nohe. + */ +function isInline( node, contextTag ) { + if ( isPhrasingContent( node ) ) { + return true; + } + + if ( ! contextTag ) { + return false; + } + + const tag = node.nodeName.toLowerCase(); + const inlineWhitelistTagGroups = [ + [ 'ul', 'li', 'ol' ], + [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], + ]; + + return inlineWhitelistTagGroups.some( ( tagGroup ) => + difference( [ tag, contextTag ], tagGroup ).length === 0 + ); +} + +function deepCheck( nodes, contextTag ) { + return nodes.every( ( node ) => + isInline( node, contextTag ) && deepCheck( Array.from( node.children ), contextTag ) + ); +} + +function isDoubleBR( node ) { + return node.nodeName === 'BR' && node.previousSibling && node.previousSibling.nodeName === 'BR'; +} + +export default function( HTML, contextTag ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = HTML; + + const nodes = Array.from( doc.body.children ); + + return ! nodes.some( isDoubleBR ) && deepCheck( nodes, contextTag ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/list-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/list-reducer.js new file mode 100644 index 0000000000000..a5f079dcb2488 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/list-reducer.js @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { unwrap } from '@wordpress/dom'; + +function isList( node ) { + return node.nodeName === 'OL' || node.nodeName === 'UL'; +} + +function shallowTextContent( element ) { + return [ ...element.childNodes ] + .map( ( { nodeValue = '' } ) => nodeValue ) + .join( '' ); +} + +export default function( node ) { + if ( ! isList( node ) ) { + return; + } + + const list = node; + const prevElement = node.previousElementSibling; + + // Merge with previous list if: + // * There is a previous list of the same type. + // * There is only one list item. + if ( + prevElement && + prevElement.nodeName === node.nodeName && + list.children.length === 1 + ) { + // Move all child nodes, including any text nodes, if any. + while ( list.firstChild ) { + prevElement.appendChild( list.firstChild ); + } + + list.parentNode.removeChild( list ); + } + + const parentElement = node.parentNode; + + // Nested list with empty parent item. + if ( + parentElement && + parentElement.nodeName === 'LI' && + parentElement.children.length === 1 && + ! /\S/.test( shallowTextContent( parentElement ) ) + ) { + const parentListItem = parentElement; + const prevListItem = parentListItem.previousElementSibling; + const parentList = parentListItem.parentNode; + + if ( prevListItem ) { + prevListItem.appendChild( list ); + parentList.removeChild( parentListItem ); + } else { + parentList.parentNode.insertBefore( list, parentList ); + parentList.parentNode.removeChild( parentList ); + } + } + + // Invalid: OL/UL > OL/UL. + if ( parentElement && isList( parentElement ) ) { + const prevListItem = node.previousElementSibling; + + if ( prevListItem ) { + prevListItem.appendChild( node ); + } else { + unwrap( node ); + } + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/markdown-converter.js b/gb-src/packages/blocks/src/api/raw-handling/markdown-converter.js new file mode 100644 index 0000000000000..ead5d6c8cfd96 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/markdown-converter.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import showdown from 'showdown'; + +// Reuse the same showdown converter. +const converter = new showdown.Converter( { + noHeaderId: true, + tables: true, + literalMidWordUnderscores: true, + omitExtraWLInCodeBlocks: true, + simpleLineBreaks: true, + strikethrough: true, +} ); + +/** + * Corrects the Slack Markdown variant of the code block. + * If uncorrected, it will be converted to inline code. + * + * @see https://get.slack.help/hc/en-us/articles/202288908-how-can-i-add-formatting-to-my-messages-#code-blocks + * + * @param {string} text The potential Markdown text to correct. + * + * @return {string} The corrected Markdown. + */ +function slackMarkdownVariantCorrector( text ) { + return text.replace( + /((?:^|\n)```)([^\n`]+)(```(?:$|\n))/, + ( match, p1, p2, p3 ) => `${ p1 }\n${ p2 }\n${ p3 }` + ); +} + +/** + * Converts a piece of text into HTML based on any Markdown present. + * Also decodes any encoded HTML. + * + * @param {string} text The plain text to convert. + * + * @return {string} HTML. + */ +export default function( text ) { + return converter.makeHtml( slackMarkdownVariantCorrector( text ) ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/ms-list-converter.js b/gb-src/packages/blocks/src/api/raw-handling/ms-list-converter.js new file mode 100644 index 0000000000000..0181517493f79 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/ms-list-converter.js @@ -0,0 +1,84 @@ +/** + * Browser dependencies + */ +const { parseInt } = window; + +function isList( node ) { + return node.nodeName === 'OL' || node.nodeName === 'UL'; +} + +export default function( node, doc ) { + if ( node.nodeName !== 'P' ) { + return; + } + + const style = node.getAttribute( 'style' ); + + if ( ! style ) { + return; + } + + // Quick check. + if ( style.indexOf( 'mso-list' ) === -1 ) { + return; + } + + const matches = /mso-list\s*:[^;]+level([0-9]+)/i.exec( style ); + + if ( ! matches ) { + return; + } + + let level = parseInt( matches[ 1 ], 10 ) - 1 || 0; + + const prevNode = node.previousElementSibling; + + // Add new list if no previous. + if ( ! prevNode || ! isList( prevNode ) ) { + // See https://html.spec.whatwg.org/multipage/grouping-content.html#attr-ol-type. + const type = node.textContent.trim().slice( 0, 1 ); + const isNumeric = /[1iIaA]/.test( type ); + const newListNode = doc.createElement( isNumeric ? 'ol' : 'ul' ); + + if ( isNumeric ) { + newListNode.setAttribute( 'type', type ); + } + + node.parentNode.insertBefore( newListNode, node ); + } + + const listNode = node.previousElementSibling; + const listType = listNode.nodeName; + const listItem = doc.createElement( 'li' ); + + let receivingNode = listNode; + + // Remove the first span with list info. + node.removeChild( node.firstElementChild ); + + // Add content. + while ( node.firstChild ) { + listItem.appendChild( node.firstChild ); + } + + // Change pointer depending on indentation level. + while ( level-- ) { + receivingNode = receivingNode.lastElementChild || receivingNode; + + // If it's a list, move pointer to the last item. + if ( isList( receivingNode ) ) { + receivingNode = receivingNode.lastElementChild || receivingNode; + } + } + + // Make sure we append to a list. + if ( ! isList( receivingNode ) ) { + receivingNode = receivingNode.appendChild( doc.createElement( listType ) ); + } + + // Append the list item to the list. + receivingNode.appendChild( listItem ); + + // Remove the wrapper paragraph. + node.parentNode.removeChild( node ); +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/normalise-blocks.js b/gb-src/packages/blocks/src/api/raw-handling/normalise-blocks.js new file mode 100644 index 0000000000000..52ca57c43e047 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/normalise-blocks.js @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { isEmpty } from './utils'; +import { isPhrasingContent } from './phrasing-content'; + +/** + * Browser dependencies + */ +const { ELEMENT_NODE, TEXT_NODE } = window.Node; + +export default function( HTML ) { + const decuDoc = document.implementation.createHTMLDocument( '' ); + const accuDoc = document.implementation.createHTMLDocument( '' ); + + const decu = decuDoc.body; + const accu = accuDoc.body; + + decu.innerHTML = HTML; + + while ( decu.firstChild ) { + const node = decu.firstChild; + + // Text nodes: wrap in a paragraph, or append to previous. + if ( node.nodeType === TEXT_NODE ) { + if ( ! node.nodeValue.trim() ) { + decu.removeChild( node ); + } else { + if ( ! accu.lastChild || accu.lastChild.nodeName !== 'P' ) { + accu.appendChild( accuDoc.createElement( 'P' ) ); + } + + accu.lastChild.appendChild( node ); + } + // Element nodes. + } else if ( node.nodeType === ELEMENT_NODE ) { + // BR nodes: create a new paragraph on double, or append to previous. + if ( node.nodeName === 'BR' ) { + if ( node.nextSibling && node.nextSibling.nodeName === 'BR' ) { + accu.appendChild( accuDoc.createElement( 'P' ) ); + decu.removeChild( node.nextSibling ); + } + + // Don't append to an empty paragraph. + if ( + accu.lastChild && + accu.lastChild.nodeName === 'P' && + accu.lastChild.hasChildNodes() + ) { + accu.lastChild.appendChild( node ); + } else { + decu.removeChild( node ); + } + } else if ( node.nodeName === 'P' ) { + // Only append non-empty paragraph nodes. + if ( isEmpty( node ) ) { + decu.removeChild( node ); + } else { + accu.appendChild( node ); + } + } else if ( isPhrasingContent( node ) ) { + if ( ! accu.lastChild || accu.lastChild.nodeName !== 'P' ) { + accu.appendChild( accuDoc.createElement( 'P' ) ); + } + accu.lastChild.appendChild( node ); + } else { + accu.appendChild( node ); + } + } else { + decu.removeChild( node ); + } + } + + return accu.innerHTML; +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/paste-handler.js b/gb-src/packages/blocks/src/api/raw-handling/paste-handler.js new file mode 100644 index 0000000000000..fdea17bd0eba2 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/paste-handler.js @@ -0,0 +1,250 @@ +/** + * External dependencies + */ +import { flatMap, filter, compact } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock, getBlockTransforms, findTransform } from '../factory'; +import { getBlockContent } from '../serializer'; +import { getBlockAttributes, parseWithGrammar } from '../parser'; +import normaliseBlocks from './normalise-blocks'; +import specialCommentConverter from './special-comment-converter'; +import commentRemover from './comment-remover'; +import isInlineContent from './is-inline-content'; +import phrasingContentReducer from './phrasing-content-reducer'; +import headRemover from './head-remover'; +import msListConverter from './ms-list-converter'; +import listReducer from './list-reducer'; +import imageCorrector from './image-corrector'; +import blockquoteNormaliser from './blockquote-normaliser'; +import figureContentReducer from './figure-content-reducer'; +import shortcodeConverter from './shortcode-converter'; +import markdownConverter from './markdown-converter'; +import iframeRemover from './iframe-remover'; +import googleDocsUIDRemover from './google-docs-uid-remover'; +import { getPhrasingContentSchema } from './phrasing-content'; +import { + deepFilterHTML, + isPlain, + removeInvalidHTML, + getBlockContentSchema, +} from './utils'; + +/** + * Browser dependencies + */ +const { console } = window; + +/** + * Filters HTML to only contain phrasing content. + * + * @param {string} HTML The HTML to filter. + * + * @return {string} HTML only containing phrasing content. + */ +function filterInlineHTML( HTML ) { + HTML = deepFilterHTML( HTML, [ googleDocsUIDRemover, phrasingContentReducer, commentRemover ] ); + HTML = removeInvalidHTML( HTML, getPhrasingContentSchema(), { inline: true } ); + + // Allows us to ask for this information when we get a report. + console.log( 'Processed inline HTML:\n\n', HTML ); + + return HTML; +} + +function getRawTransformations() { + return filter( getBlockTransforms( 'from' ), { type: 'raw' } ) + .map( ( transform ) => { + return transform.isMatch ? transform : { + ...transform, + isMatch: ( node ) => transform.selector && node.matches( transform.selector ), + }; + } ); +} + +/** + * Converts HTML directly to blocks. Looks for a matching transform for each + * top-level tag. The HTML should be filtered to not have any text between + * top-level tags and formatted in a way that blocks can handle the HTML. + * + * @param {Object} $1 Named parameters. + * @param {string} $1.html HTML to convert. + * @param {Array} $1.rawTransforms Transforms that can be used. + * + * @return {Array} An array of blocks. + */ +function htmlToBlocks( { html, rawTransforms } ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = html; + + return Array.from( doc.body.children ).map( ( node ) => { + const rawTransform = findTransform( rawTransforms, ( { isMatch } ) => isMatch( node ) ); + + if ( ! rawTransform ) { + return createBlock( + // Should not be hardcoded. + 'core/html', + getBlockAttributes( + 'core/html', + node.outerHTML + ) + ); + } + + const { transform, blockName } = rawTransform; + + if ( transform ) { + return transform( node ); + } + + return createBlock( + blockName, + getBlockAttributes( + blockName, + node.outerHTML + ) + ); + } ); +} + +/** + * Converts an HTML string to known blocks. Strips everything else. + * + * @param {Object} options + * @param {string} [options.HTML] The HTML to convert. + * @param {string} [options.plainText] Plain text version. + * @param {string} [options.mode] Handle content as blocks or inline content. + * * 'AUTO': Decide based on the content passed. + * * 'INLINE': Always handle as inline content, and return string. + * * 'BLOCKS': Always handle as blocks, and return array of blocks. + * @param {Array} [options.tagName] The tag into which content will be inserted. + * @param {boolean} [options.canUserUseUnfilteredHTML] Whether or not the user can use unfiltered HTML. + * + * @return {Array|string} A list of blocks or a string, depending on `handlerMode`. + */ +export function pasteHandler( { HTML = '', plainText = '', mode = 'AUTO', tagName, canUserUseUnfilteredHTML = false } ) { + // First of all, strip any meta tags. + HTML = HTML.replace( /<meta[^>]+>/, '' ); + + // If we detect block delimiters in HTML, parse entirely as blocks. + if ( mode !== 'INLINE' ) { + // Check plain text if there is no HTML. + const content = HTML ? HTML : plainText; + + if ( content.indexOf( '<!-- wp:' ) !== -1 ) { + return parseWithGrammar( content ); + } + } + + // Normalize unicode to use composed characters. + // This is unsupported in IE 11 but it's a nice-to-have feature, not mandatory. + // Not normalizing the content will only affect older browsers and won't + // entirely break the app. + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize + // See: https://core.trac.wordpress.org/ticket/30130 + // See: https://github.com/WordPress/gutenberg/pull/6983#pullrequestreview-125151075 + if ( String.prototype.normalize ) { + HTML = HTML.normalize(); + } + + // Parse Markdown (and encoded HTML) if: + // * There is a plain text version. + // * There is no HTML version, or it has no formatting. + if ( plainText && ( ! HTML || isPlain( HTML ) ) ) { + HTML = markdownConverter( plainText ); + + // Switch to inline mode if: + // * The current mode is AUTO. + // * The original plain text had no line breaks. + // * The original plain text was not an HTML paragraph. + // * The converted text is just a paragraph. + if ( + mode === 'AUTO' && + plainText.indexOf( '\n' ) === -1 && + plainText.indexOf( '<p>' ) !== 0 && + HTML.indexOf( '<p>' ) === 0 + ) { + mode = 'INLINE'; + } + } + + if ( mode === 'INLINE' ) { + return filterInlineHTML( HTML ); + } + + // An array of HTML strings and block objects. The blocks replace matched + // shortcodes. + const pieces = shortcodeConverter( HTML ); + + // The call to shortcodeConverter will always return more than one element + // if shortcodes are matched. The reason is when shortcodes are matched + // empty HTML strings are included. + const hasShortcodes = pieces.length > 1; + + if ( mode === 'AUTO' && ! hasShortcodes && isInlineContent( HTML, tagName ) ) { + return filterInlineHTML( HTML ); + } + + const rawTransforms = getRawTransformations(); + const phrasingContentSchema = getPhrasingContentSchema(); + const blockContentSchema = getBlockContentSchema( rawTransforms ); + + const blocks = compact( flatMap( pieces, ( piece ) => { + // Already a block from shortcode. + if ( typeof piece !== 'string' ) { + return piece; + } + + const filters = [ + googleDocsUIDRemover, + msListConverter, + headRemover, + listReducer, + imageCorrector, + phrasingContentReducer, + specialCommentConverter, + commentRemover, + figureContentReducer, + blockquoteNormaliser, + ]; + + if ( ! canUserUseUnfilteredHTML ) { + // Should run before `figureContentReducer`. + filters.unshift( iframeRemover ); + } + + const schema = { + ...blockContentSchema, + // Keep top-level phrasing content, normalised by `normaliseBlocks`. + ...phrasingContentSchema, + }; + + piece = deepFilterHTML( piece, filters, blockContentSchema ); + piece = removeInvalidHTML( piece, schema ); + piece = normaliseBlocks( piece ); + + // Allows us to ask for this information when we get a report. + console.log( 'Processed HTML piece:\n\n', piece ); + + return htmlToBlocks( { html: piece, rawTransforms } ); + } ) ); + + // If we're allowed to return inline content and there is only one block + // and the original plain text content does not have any line breaks, then + // treat it as inline paste. + if ( mode === 'AUTO' && blocks.length === 1 ) { + const trimmedPlainText = plainText.trim(); + + if ( trimmedPlainText !== '' && trimmedPlainText.indexOf( '\n' ) === -1 ) { + return removeInvalidHTML( + getBlockContent( blocks[ 0 ] ), + phrasingContentSchema + ); + } + } + + return blocks; +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/phrasing-content-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/phrasing-content-reducer.js new file mode 100644 index 0000000000000..5c5ef223298fe --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/phrasing-content-reducer.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { wrap, replaceTag } from '@wordpress/dom'; + +export default function( node, doc ) { + // In jsdom-jscore, 'node.style' can be null. + // TODO: Explore fixing this by patching jsdom-jscore. + if ( node.nodeName === 'SPAN' && node.style ) { + const { + fontWeight, + fontStyle, + textDecorationLine, + verticalAlign, + } = node.style; + + if ( fontWeight === 'bold' || fontWeight === '700' ) { + wrap( doc.createElement( 'strong' ), node ); + } + + if ( fontStyle === 'italic' ) { + wrap( doc.createElement( 'em' ), node ); + } + + if ( textDecorationLine === 'line-through' ) { + wrap( doc.createElement( 's' ), node ); + } + + if ( verticalAlign === 'super' ) { + wrap( doc.createElement( 'sup' ), node ); + } else if ( verticalAlign === 'sub' ) { + wrap( doc.createElement( 'sub' ), node ); + } + } else if ( node.nodeName === 'B' ) { + node = replaceTag( node, 'strong' ); + } else if ( node.nodeName === 'I' ) { + node = replaceTag( node, 'em' ); + } else if ( node.nodeName === 'A' ) { + // In jsdom-jscore, 'node.target' can be null. + // TODO: Explore fixing this by patching jsdom-jscore. + if ( node.target && node.target.toLowerCase() === '_blank' ) { + node.rel = 'noreferrer noopener'; + } else { + node.removeAttribute( 'target' ); + node.removeAttribute( 'rel' ); + } + } +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/phrasing-content.js b/gb-src/packages/blocks/src/api/raw-handling/phrasing-content.js new file mode 100644 index 0000000000000..028c61e9b8958 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/phrasing-content.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +const phrasingContentSchema = { + strong: {}, + em: {}, + s: {}, + del: {}, + ins: {}, + a: { attributes: [ 'href', 'target', 'rel' ] }, + code: {}, + abbr: { attributes: [ 'title' ] }, + sub: {}, + sup: {}, + br: {}, + '#text': {}, +}; + +// Recursion is needed. +// Possible: strong > em > strong. +// Impossible: strong > strong. +[ 'strong', 'em', 's', 'del', 'ins', 'a', 'code', 'abbr', 'sub', 'sup' ].forEach( ( tag ) => { + phrasingContentSchema[ tag ].children = omit( phrasingContentSchema, tag ); +} ); + +/** + * Get schema of possible paths for phrasing content. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content + * + * @return {Object} Schema. + */ +export function getPhrasingContentSchema() { + return phrasingContentSchema; +} + +/** + * Find out whether or not the given node is phrasing content. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content + * + * @param {Element} node The node to test. + * + * @return {boolean} True if phrasing content, false if not. + */ +export function isPhrasingContent( node ) { + const tag = node.nodeName.toLowerCase(); + return getPhrasingContentSchema().hasOwnProperty( tag ) || tag === 'span'; +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/readme.md b/gb-src/packages/blocks/src/api/raw-handling/readme.md new file mode 100644 index 0000000000000..f3efed00b301c --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/readme.md @@ -0,0 +1,28 @@ +# Raw Handling (Paste) + +This folder contains all paste specific logic (filters, converters, normalisers...). Each module is tested on their own, and in addition we have some integration tests for frequently used editors. + +## Support table + +| Source | Formatting | Headings | Lists | Image | Separator | Table | +| ---------------- | ---------- | -------- | ----- | ----- | --------- | ----- | +| Google Docs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Apple Pages | ✓ | ✘ [1] | ✓ | ✘ [1] | n/a | ✓ | +| MS Word | ✓ | ✓ | ✓ | ✘ [2] | n/a | ✓ | +| MS Word Online | ✓ | ✘ [3] | ✓ | ✓ | n/a | ✓ | +| Evernote | ✓ | ✘ [4] | ✓ | ✓ | ✓ | ✓ | +| Markdown | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Legacy WordPress | ✓ | ✓ | ✓ | … [5] | ✓ | ✓ | +| Web | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | + + +1. Apple Pages does not pass heading and image information. +2. MS Word only provides a local file path, which cannot be accessed in JavaScript for security reasons. Image placeholders will be provided instead. Single images, however, _can_ be copied and pasted without any problem. +3. Still to do for MS Word Online. +4. Evernote does not have headings. +5. For caption and gallery shortcodes, see #2874. + +## Other notable capabilities + +* Filters out analytics trackers in the form of images. +* Pasting a single images (if the browser provides image data). diff --git a/gb-src/packages/blocks/src/api/raw-handling/shortcode-converter.js b/gb-src/packages/blocks/src/api/raw-handling/shortcode-converter.js new file mode 100644 index 0000000000000..5385f0aed4150 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/shortcode-converter.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { some, castArray, first, mapValues, pickBy, includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { regexp, next } from '@wordpress/shortcode'; + +/** + * Internal dependencies + */ +import { createBlock, getBlockTransforms, findTransform } from '../factory'; +import { getBlockType } from '../registration'; +import { getBlockAttributes } from '../parser'; + +function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0 ) { + // Get all matches. + const transformsFrom = getBlockTransforms( 'from' ); + + const transformation = findTransform( transformsFrom, ( transform ) => ( + transform.type === 'shortcode' && + some( castArray( transform.tag ), ( tag ) => regexp( tag ).test( HTML ) ) + ) ); + + if ( ! transformation ) { + return [ HTML ]; + } + + const transformTags = castArray( transformation.tag ); + const transformTag = first( transformTags ); + + let match; + + if ( ( match = next( transformTag, HTML, lastIndex ) ) ) { + const beforeHTML = HTML.substr( 0, match.index ); + + lastIndex = match.index + match.content.length; + + // If the shortcode content does not contain HTML and the shortcode is + // not on a new line (or in paragraph from Markdown converter), + // consider the shortcode as inline text, and thus skip conversion for + // this segment. + if ( + ! includes( match.shortcode.content || '', '<' ) && + ! /(\n|<p>)\s*$/.test( beforeHTML ) + ) { + return segmentHTMLToShortcodeBlock( HTML, lastIndex ); + } + + const attributes = mapValues( + pickBy( transformation.attributes, ( schema ) => schema.shortcode ), + // Passing all of `match` as second argument is intentionally broad + // but shouldn't be too relied upon. + // + // See: https://github.com/WordPress/gutenberg/pull/3610#discussion_r152546926 + ( schema ) => schema.shortcode( match.shortcode.attrs, match ), + ); + + const block = createBlock( + transformation.blockName, + getBlockAttributes( + { + ...getBlockType( transformation.blockName ), + attributes: transformation.attributes, + }, + match.shortcode.content, + attributes, + ) + ); + + return [ + beforeHTML, + block, + ...segmentHTMLToShortcodeBlock( HTML.substr( lastIndex ) ), + ]; + } + + return [ HTML ]; +} + +export default segmentHTMLToShortcodeBlock; diff --git a/gb-src/packages/blocks/src/api/raw-handling/special-comment-converter.js b/gb-src/packages/blocks/src/api/raw-handling/special-comment-converter.js new file mode 100644 index 0000000000000..4564c5be0b0fe --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/special-comment-converter.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { remove, replace } from '@wordpress/dom'; + +/** + * Browser dependencies + */ +const { COMMENT_NODE } = window.Node; + +/** + * Looks for `<!--nextpage-->` and `<!--more-->` comments, as well as the + * `<!--more Some text-->` variant and its `<!--noteaser-->` companion, + * and replaces them with a custom element representing a future block. + * + * The custom element is a way to bypass the rest of the `raw-handling` + * transforms, which would eliminate other kinds of node with which to carry + * `<!--more-->`'s data: nodes with `data` attributes, empty paragraphs, etc. + * + * The custom element is then expected to be recognized by any registered + * block's `raw` transform. + * + * @param {Node} node The node to be processed. + * @param {Document} doc The document of the node. + * @return {void} + */ +export default function( node, doc ) { + if ( node.nodeType !== COMMENT_NODE ) { + return; + } + + if ( node.nodeValue === 'nextpage' ) { + replace( node, createNextpage( doc ) ); + return; + } + + if ( node.nodeValue.indexOf( 'more' ) === 0 ) { + // Grab any custom text in the comment. + const customText = node.nodeValue.slice( 4 ).trim(); + + /* + * When a `<!--more-->` comment is found, we need to look for any + * `<!--noteaser-->` sibling, but it may not be a direct sibling + * (whitespace typically lies in between) + */ + let sibling = node; + let noTeaser = false; + while ( ( sibling = sibling.nextSibling ) ) { + if ( + sibling.nodeType === COMMENT_NODE && + sibling.nodeValue === 'noteaser' + ) { + noTeaser = true; + remove( sibling ); + break; + } + } + + replace( node, createMore( customText, noTeaser, doc ) ); + } +} + +function createMore( customText, noTeaser, doc ) { + const node = doc.createElement( 'wp-block' ); + node.dataset.block = 'core/more'; + if ( customText ) { + node.dataset.customText = customText; + } + if ( noTeaser ) { + // "Boolean" data attribute + node.dataset.noTeaser = ''; + } + return node; +} + +function createNextpage( doc ) { + const node = doc.createElement( 'wp-block' ); + node.dataset.block = 'core/nextpage'; + + return node; +} diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/blockquote-normaliser.js b/gb-src/packages/blocks/src/api/raw-handling/test/blockquote-normaliser.js new file mode 100644 index 0000000000000..fa3f9660fd2d5 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/blockquote-normaliser.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import blockquoteNormaliser from '../blockquote-normaliser'; +import { deepFilterHTML } from '../utils'; + +describe( 'blockquoteNormaliser', () => { + it( 'should normalise blockquote', () => { + const input = '<blockquote>test</blockquote>'; + const output = '<blockquote><p>test</p></blockquote>'; + expect( deepFilterHTML( input, [ blockquoteNormaliser ] ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/comment-remover.js b/gb-src/packages/blocks/src/api/raw-handling/test/comment-remover.js new file mode 100644 index 0000000000000..6721e20bec23b --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/comment-remover.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import commentRemover from '../comment-remover'; +import { deepFilterHTML } from '../utils'; + +describe( 'commentRemover', () => { + it( 'should remove a single comment', () => { + expect( deepFilterHTML( + '<!-- Comment -->', + [ commentRemover ] + ) ).toEqual( + '' + ); + } ); + it( 'should remove multiple comments', () => { + expect( deepFilterHTML( + '<!-- First comment --><p>First paragraph.</p><!-- Second comment --><p>Second paragraph.</p><!-- Third comment -->', + [ commentRemover ] + ) ).toEqual( + '<p>First paragraph.</p><p>Second paragraph.</p>' + ); + } ); + it( 'should remove nested comments', () => { + expect( deepFilterHTML( + '<p>Paragraph.<!-- Comment --></p>', + [ commentRemover ] + ) ).toEqual( + '<p>Paragraph.</p>' + ); + } ); + it( 'should remove multi-line comments', () => { + expect( deepFilterHTML( + `<p>First paragraph.</p><!-- + Multi-line + comment + --><p>Second paragraph.</p>`, + [ commentRemover ] + ) ).toEqual( + '<p>First paragraph.</p><p>Second paragraph.</p>' + ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/figure-content-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/test/figure-content-reducer.js new file mode 100644 index 0000000000000..d8346b81fb9a2 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/figure-content-reducer.js @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import figureContentReducer from '../figure-content-reducer'; +import { deepFilterHTML } from '../utils'; + +describe( 'figureContentReducer', () => { + const schema = { + figure: { + children: { + img: {}, + a: { + children: { + img: {}, + }, + }, + }, + }, + }; + + it( 'should move embedded content from paragraph', () => { + const input = '<p><strong>test<img class="one"></strong><img class="two"></p>'; + const output = '<figure><img class="one"></figure><figure><img class="two"></figure><p><strong>test</strong></p>'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); + + it( 'should move an anchor with just an image from paragraph', () => { + const input = '<p><a href="#"><img class="one"></a><strong>test</strong></p>'; + const output = '<figure><a href="#"><img class="one"></a></figure><p><strong>test</strong></p>'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); + + it( 'should move multiple images', () => { + const input = '<p><a href="#"><img class="one"></a><img class="two"><strong>test</strong></p>'; + const output = '<figure><a href="#"><img class="one"></a></figure><figure><img class="two"></figure><p><strong>test</strong></p>'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/image-corrector.js b/gb-src/packages/blocks/src/api/raw-handling/test/image-corrector.js new file mode 100644 index 0000000000000..e4301583ceff9 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/image-corrector.js @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import imageCorrector from '../image-corrector'; +import { deepFilterHTML } from '../utils'; + +describe( 'imageCorrector', () => { + it( 'should correct image source', () => { + const input = '<img src="file:LOW-RES.png">'; + const output = '<img src="">'; + expect( deepFilterHTML( input, [ imageCorrector ] ) ).toEqual( output ); + } ); + + it( 'should remove trackers', () => { + const input = '<img src="" height="1" width="1">'; + const output = ''; + expect( deepFilterHTML( input, [ imageCorrector ] ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/is-inline-content.js b/gb-src/packages/blocks/src/api/raw-handling/test/is-inline-content.js new file mode 100644 index 0000000000000..09c80cbea8232 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/is-inline-content.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import isInlineContent from '../is-inline-content'; + +describe( 'isInlineContent', () => { + it( 'should be inline content', () => { + expect( isInlineContent( '<em>test</em>' ) ).toBe( true ); + expect( isInlineContent( '<span>test</span>' ) ).toBe( true ); + expect( isInlineContent( '<li>test</li>', 'ul' ) ).toBe( true ); + } ); + + it( 'should not be inline content', () => { + expect( isInlineContent( '<div>test</div>' ) ).toBe( false ); + expect( isInlineContent( '<em>test</em><div>test</div>' ) ).toBe( false ); + expect( isInlineContent( 'test<br><br>test' ) ).toBe( false ); + expect( isInlineContent( '<em><div>test</div></em>' ) ).toBe( false ); + expect( isInlineContent( '<li>test</li>', 'p' ) ).toBe( false ); + expect( isInlineContent( '<li>test</li>', 'h1' ) ).toBe( false ); + expect( isInlineContent( '<h1>test</h1>', 'li' ) ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/list-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/test/list-reducer.js new file mode 100644 index 0000000000000..9d1b053627295 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/list-reducer.js @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import listReducer from '../list-reducer'; +import { deepFilterHTML } from '../utils'; + +describe( 'listReducer', () => { + it( 'should merge lists', () => { + const input = '<ul><li>one</li></ul><ul><li>two</li></ul>'; + const output = '<ul><li>one</li><li>two</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); + + it( 'should merge lists with whitespace', () => { + const input = '<ul>\n<li>one</li>\n</ul>\n<ul>\n<li>two</li>\n</ul>'; + const output = '<ul>\n<li>one</li>\n\n<li>two</li>\n</ul>\n'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); + + it( 'should not merge lists if it has more than one item', () => { + const input = '<ul><li>one</li></ul><ul><li>two</li><li>three</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( input ); + } ); + + it( 'should not merge list if the type is different', () => { + const input = '<ul><li>one</li></ul><ol><li>two</li></ol>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( input ); + } ); + + it( 'should not merge lists if there is something in between', () => { + const input = '<ul><li>one</li></ul><p></p><ul><li>two</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( input ); + } ); + + it( 'should merge list items if nested list parent has no content', () => { + const input = '<ul><li>1</li><li><ul><li>1.1</li><li>1.2</li></ul></li><li>2</li></ul>'; + const output = '<ul><li>1<ul><li>1.1</li><li>1.2</li></ul></li><li>2</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); + + it( 'should remove empty list wrappers', () => { + const input = '<ul><li>\n<ul><li>test</li></ul>\n</li></ul>'; + const output = '<ul><li>test</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); + + it( 'should not remove filled list wrappers', () => { + const input = '<ul><li>\ntest\n<ul><li>test</li></ul>\n</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( input ); + } ); + + it( 'should adjust wrong indentation (1)', () => { + const input = '<ul><li>test</li><ul><li>test</li></ul></ul>'; + const output = '<ul><li>test<ul><li>test</li></ul></li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); + + it( 'should adjust wrong indentation (2)', () => { + const input = '<ul><ul><li>test</li></ul><li>test</li></ul>'; + const output = '<ul><li>test</li><li>test</li></ul>'; + expect( deepFilterHTML( input, [ listReducer ] ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/markdown-converter.js b/gb-src/packages/blocks/src/api/raw-handling/test/markdown-converter.js new file mode 100644 index 0000000000000..32cc4c865fe19 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/markdown-converter.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import markdownConverter from '../markdown-converter'; + +describe( 'markdownConverter', () => { + it( 'should correct Slack variant', () => { + const input = '```test```'; + const output = '<pre><code>test</code></pre>'; + expect( markdownConverter( input ) ).toEqual( output ); + } ); + + it( 'should correct Slack variant on own line', () => { + const input = 'test\n```test```\ntest'; + const output = '<p>test</p>\n<pre><code>test</code></pre>\n<p>test</p>'; + expect( markdownConverter( input ) ).toEqual( output ); + } ); + + it( 'should not correct inline code', () => { + const input = 'test ```test``` test'; + const output = '<p>test <code>test</code> test</p>'; + expect( markdownConverter( input ) ).toEqual( output ); + } ); + + it( 'should not correct code with line breaks', () => { + const input = '```js\ntest\n```'; + const output = '<pre><code class=\"js language-js\">test</code></pre>'; + expect( markdownConverter( input ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/ms-list-converter.js b/gb-src/packages/blocks/src/api/raw-handling/test/ms-list-converter.js new file mode 100644 index 0000000000000..5a64cab01c9f0 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/ms-list-converter.js @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import msListConverter from '../ms-list-converter'; +import { deepFilterHTML } from '../utils'; + +describe( 'msListConverter', () => { + it( 'should convert unordered list', () => { + const input = '<p style="mso-list:l0 level1 lfo1"><span>* </span>test</p>'; + const output = '<ul><li>test</li></ul>'; + expect( deepFilterHTML( input, [ msListConverter ] ) ).toEqual( output ); + } ); + + it( 'should convert ordered list', () => { + const input = '<p style="mso-list:l0 level1 lfo1"><span>1 </span>test</p>'; + const output = '<ol type="1"><li>test</li></ol>'; + expect( deepFilterHTML( input, [ msListConverter ] ) ).toEqual( output ); + } ); + + it( 'should convert indented list', () => { + const input1 = '<p style="mso-list:l0 level1 lfo1"><span>* </span>test</p>'; + const input2 = '<p style="mso-list:l0 level2 lfo1"><span>* </span>test</p>'; + const input3 = '<p style="mso-list:l0 level1 lfo1"><span>* </span>test</p>'; + const output = '<ul><li>test<ul><li>test</li></ul></li><li>test</li></ul>'; + expect( deepFilterHTML( input1 + input2 + input3, [ msListConverter ] ) ).toEqual( output ); + } ); + + it( 'should convert deep indented list', () => { + const input1 = '<p style="mso-list:l0 level1 lfo1"><span>* </span>test</p>'; + const input2 = '<p style="mso-list:l0 level2 lfo1"><span>* </span>test</p>'; + const input3 = '<p style="mso-list:l0 level3 lfo1"><span>* </span>test</p>'; + const input4 = '<p style="mso-list:l0 level1 lfo1"><span>* </span>test</p>'; + const output = '<ul><li>test<ul><li>test<ul><li>test</li></ul></li></ul></li><li>test</li></ul>'; + expect( deepFilterHTML( input1 + input2 + input3 + input4, [ msListConverter ] ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/normalise-blocks.js b/gb-src/packages/blocks/src/api/raw-handling/test/normalise-blocks.js new file mode 100644 index 0000000000000..f5c9cba20f706 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/normalise-blocks.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import normaliseBlocks from '../normalise-blocks'; + +describe( 'normaliseBlocks', () => { + it( 'should convert double line breaks to paragraphs', () => { + expect( normaliseBlocks( 'test<br><br>test' ) ).toEqual( '<p>test</p><p>test</p>' ); + } ); + + it( 'should not convert single line break to paragraphs', () => { + expect( normaliseBlocks( 'test<br>test' ) ).toEqual( '<p>test<br>test</p>' ); + } ); + + it( 'should not add extra line at the start', () => { + expect( normaliseBlocks( 'test<br><br><br>test' ) ).toEqual( '<p>test</p><p>test</p>' ); + expect( normaliseBlocks( '<br>test<br><br>test' ) ).toEqual( '<p>test</p><p>test</p>' ); + } ); + + it( 'should preserve non-inline content', () => { + const HTML = '<p>test</p><div>test<br>test</div>'; + expect( normaliseBlocks( HTML ) ).toEqual( HTML ); + } ); + + it( 'should remove empty paragraphs', () => { + expect( normaliseBlocks( '<p> </p>' ) ).toEqual( '' ); + } ); + + it( 'should wrap lose inline elements', () => { + expect( normaliseBlocks( '<a href="#">test</a>' ) ).toEqual( '<p><a href="#">test</a></p>' ); + } ); + + it( 'should not break between inline siblings', () => { + expect( normaliseBlocks( '<strong>test</strong> is a test of <a href="#">test</a> using a <a href="#">test</a>.' ) ).toEqual( '<p><strong>test</strong> is a test of <a href="#">test</a> using a <a href="#">test</a>.</p>' ); + } ); + + it( 'should not append empty text nodes', () => { + expect( normaliseBlocks( '<p>test</p>\n' ) ).toEqual( '<p>test</p>' ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/phrasing-content-reducer.js b/gb-src/packages/blocks/src/api/raw-handling/test/phrasing-content-reducer.js new file mode 100644 index 0000000000000..14a9d26984956 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/phrasing-content-reducer.js @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import phrasingContentReducer from '../phrasing-content-reducer'; +import { deepFilterHTML } from '../utils'; + +describe( 'phrasingContentReducer', () => { + it( 'should transform font weight', () => { + expect( deepFilterHTML( '<span style="font-weight:bold">test</span>', [ phrasingContentReducer ], {} ) ).toEqual( '<strong><span style="font-weight:bold">test</span></strong>' ); + } ); + + it( 'should transform numeric font weight', () => { + expect( deepFilterHTML( '<span style="font-weight:700">test</span>', [ phrasingContentReducer ], {} ) ).toEqual( '<strong><span style="font-weight:700">test</span></strong>' ); + } ); + + it( 'should transform font style', () => { + expect( deepFilterHTML( '<span style="font-style:italic">test</span>', [ phrasingContentReducer ], {} ) ).toEqual( '<em><span style="font-style:italic">test</span></em>' ); + } ); + + it( 'should transform nested formatting', () => { + expect( deepFilterHTML( '<span style="font-style:italic;font-weight:bold">test</span>', [ phrasingContentReducer ], {} ) ).toEqual( '<strong><em><span style="font-style:italic;font-weight:bold">test</span></em></strong>' ); + } ); + + it( 'should normalise the rel attribute', () => { + const input = '<a href="https://wordpress.org" target="_blank">WordPress</a>'; + const output = '<a href="https://wordpress.org" target="_blank" rel="noreferrer noopener">WordPress</a>'; + expect( deepFilterHTML( input, [ phrasingContentReducer ], {} ) ).toEqual( output ); + } ); + + it( 'should only allow target="_blank"', () => { + const input = '<a href="https://wordpress.org" target="_self">WordPress</a>'; + const output = '<a href="https://wordpress.org">WordPress</a>'; + expect( deepFilterHTML( input, [ phrasingContentReducer ], {} ) ).toEqual( output ); + } ); + + it( 'should remove the rel attribute when target is not set', () => { + const input = '<a href="https://wordpress.org" rel="noopener">WordPress</a>'; + const output = '<a href="https://wordpress.org">WordPress</a>'; + expect( deepFilterHTML( input, [ phrasingContentReducer ], {} ) ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/special-comment-converter.js b/gb-src/packages/blocks/src/api/raw-handling/test/special-comment-converter.js new file mode 100644 index 0000000000000..528dd430d3607 --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/special-comment-converter.js @@ -0,0 +1,96 @@ +/** + * Internal dependencies + */ +import specialCommentConverter from '../special-comment-converter'; +import { deepFilterHTML } from '../utils'; + +describe( 'specialCommentConverter', () => { + it( 'should convert a single "more" comment into a basic block', () => { + expect( deepFilterHTML( + '<p><!--more--></p>', + [ specialCommentConverter ] + ) ).toEqual( + '<p></p><wp-block data-block="core/more"></wp-block>' + ); + } ); + it( 'should convert a single "nextpage" comment into a basic block', () => { + expect( + deepFilterHTML( '<p><!--nextpage--></p>', [ specialCommentConverter ] ) + ).toEqual( + '<p></p><wp-block data-block="core/nextpage"></wp-block>' + ); + } ); + it( 'should convert two comments into a block', () => { + expect( deepFilterHTML( + '<p><!--more--><!--noteaser--></p>', + [ specialCommentConverter ] + ) ).toEqual( + '<p></p><wp-block data-block="core/more" data-no-teaser=""></wp-block>' + ); + } ); + it( 'should pass custom text to the block', () => { + expect( deepFilterHTML( + '<p><!--more Read all about it!--><!--noteaser--></p>', + [ specialCommentConverter ] + ) ).toEqual( + '<p></p><wp-block data-block="core/more" data-custom-text="Read all about it!" data-no-teaser=""></wp-block>' + ); + } ); + it( 'should not break content order', () => { + const output = deepFilterHTML( + `<p>First paragraph.<!--more--></p> + <p>Second paragraph</p> + <p>Third paragraph</p>`, + [ specialCommentConverter ] + ); + expect( output ).toEqual( + `<p>First paragraph.</p><wp-block data-block=\"core/more\"></wp-block> + <p>Second paragraph</p> + <p>Third paragraph</p>` + ); + } ); + + describe( 'when tags have been reformatted', () => { + it( 'should parse special comments', () => { + const output = deepFilterHTML( + '<p><!--more--><!--noteaser--></p>', + [ specialCommentConverter ] + ); + expect( output ).toEqual( + '<p></p><wp-block data-block="core/more" data-no-teaser=""></wp-block>' + ); + } ); + it( 'should not break content order', () => { + const output = deepFilterHTML( + `<p>First paragraph.</p> + <p><!--more--></p> + <p>Second paragraph</p> + <p>Third paragraph</p>`, + [ specialCommentConverter ] + ); + expect( output ).toEqual( + `<p>First paragraph.</p> + <p></p><wp-block data-block=\"core/more\"></wp-block> + <p>Second paragraph</p> + <p>Third paragraph</p>` + ); + } ); + it( 'should not break pagination order', () => { + const output = deepFilterHTML( + `<p>First page.</p> + <p><!--nextpage--></p> + <p>Second page</p> + <p><!--nextpage--></p> + <p>Third page</p>`, + [ specialCommentConverter ] + ); + expect( output ).toEqual( + `<p>First page.</p> + <p></p><wp-block data-block=\"core/nextpage\"></wp-block> + <p>Second page</p> + <p></p><wp-block data-block=\"core/nextpage\"></wp-block> + <p>Third page</p>` + ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/test/utils.js b/gb-src/packages/blocks/src/api/raw-handling/test/utils.js new file mode 100644 index 0000000000000..efaea872497ae --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/test/utils.js @@ -0,0 +1,332 @@ + +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { getPhrasingContentSchema } from '../phrasing-content'; +import { getBlockContentSchema, isEmpty, isPlain, removeInvalidHTML } from '../utils'; + +jest.mock( '@wordpress/data', () => { + return { + select: jest.fn( ( store ) => { + switch ( store ) { + case 'core/blocks': { + return { + hasBlockSupport: ( blockName, supports ) => { + return blockName === 'core/paragraph' && + supports === 'anchor'; + }, + }; + } + } + } ), + }; +} ); + +describe( 'isEmpty', () => { + function isEmptyHTML( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = HTML; + + return isEmpty( doc.body ); + } + + it( 'should return true for empty element', () => { + expect( isEmptyHTML( '' ) ).toBe( true ); + } ); + + it( 'should return true for element with only whitespace', () => { + expect( isEmptyHTML( ' ' ) ).toBe( true ); + } ); + + it( 'should return true for element with non breaking space', () => { + expect( isEmptyHTML( ' ' ) ).toBe( true ); + } ); + + it( 'should return true for element with BR', () => { + expect( isEmptyHTML( '<br>' ) ).toBe( true ); + } ); + + it( 'should return true for element with empty element', () => { + expect( isEmptyHTML( '<em></em>' ) ).toBe( true ); + } ); + + it( 'should return false for element with image', () => { + expect( isEmptyHTML( '<img src="">' ) ).toBe( false ); + } ); + + it( 'should return true for element with mixed empty pieces', () => { + expect( isEmptyHTML( ' <br><br><em>  </em>' ) ).toBe( true ); + } ); +} ); + +describe( 'isPlain', () => { + it( 'should return true for plain text', () => { + expect( isPlain( 'test' ) ).toBe( true ); + } ); + + it( 'should return true for only line breaks', () => { + expect( isPlain( 'test<br>test' ) ).toBe( true ); + expect( isPlain( 'test<br/>test' ) ).toBe( true ); + expect( isPlain( 'test<br />test' ) ).toBe( true ); + expect( isPlain( 'test<br data-test>test' ) ).toBe( true ); + } ); + + it( 'should return false for formatted text', () => { + expect( isPlain( '<strong>test</strong>' ) ).toBe( false ); + expect( isPlain( '<strong>test<br></strong>' ) ).toBe( false ); + expect( isPlain( 'test<br-custom>test' ) ).toBe( false ); + } ); +} ); + +describe( 'removeInvalidHTML', () => { + const phrasingContentSchema = getPhrasingContentSchema(); + const schema = { + p: { + children: phrasingContentSchema, + }, + figure: { + require: [ 'img' ], + children: { + img: { + attributes: [ 'src', 'alt' ], + classes: [ 'alignleft' ], + }, + figcaption: { + children: phrasingContentSchema, + }, + }, + }, + ...phrasingContentSchema, + }; + + it( 'should leave plain text alone', () => { + const input = 'test'; + expect( removeInvalidHTML( input, schema ) ).toBe( input ); + } ); + + it( 'should leave valid phrasing content alone', () => { + const input = '<strong>test</strong>'; + expect( removeInvalidHTML( input, schema ) ).toBe( input ); + } ); + + it( 'should remove unrecognised tags from phrasing content', () => { + const input = '<strong><div>test</div></strong>'; + const output = '<strong>test</strong>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove unwanted whitespace outside phrasing content', () => { + const input = '<figure><img src=""> </figure>'; + const output = '<figure><img src=""></figure>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove attributes', () => { + const input = '<p class="test">test</p>'; + const output = '<p>test</p>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove id attributes', () => { + const input = '<p id="foo">test</p>'; + const output = '<p>test</p>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove multiple attributes', () => { + const input = '<p class="test" id="test">test</p>'; + const output = '<p>test</p>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should deep remove attributes', () => { + const input = '<p class="test">test <em id="test">test</em></p>'; + const output = '<p>test <em>test</em></p>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove data-* attributes', () => { + const input = '<p data-reactid="1">test</p>'; + const output = '<p>test</p>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should keep some attributes', () => { + const input = '<a href="#keep" target="_blank">test</a>'; + const output = '<a href="#keep" target="_blank">test</a>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should keep some classes', () => { + const input = '<figure><img class="alignleft test" src=""></figure>'; + const output = '<figure><img class="alignleft" src=""></figure>'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove empty nodes that should have children', () => { + const input = '<figure> </figure>'; + const output = ''; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should break up block content with phrasing schema', () => { + const input = '<p>test</p><p>test</p>'; + const output = 'test<br>test'; + expect( removeInvalidHTML( input, phrasingContentSchema, true ) ).toBe( output ); + } ); + + it( 'should unwrap node that does not satisfy require', () => { + const input = '<figure><p>test</p><figcaption>test</figcaption></figure>'; + const output = '<p>test</p>test'; + expect( removeInvalidHTML( input, schema ) ).toBe( output ); + } ); + + it( 'should remove invalid phrasing content', () => { + const input = '<strong><p>test</p></strong>'; + const output = '<p>test</p>'; + expect( removeInvalidHTML( input, schema ) ).toEqual( output ); + } ); +} ); + +describe( 'getBlockContentSchema', () => { + const myContentSchema = { + strong: {}, + em: {}, + }; + + it( 'should handle a single raw transform', () => { + const transforms = deepFreeze( [ { + blockName: 'core/paragraph', + type: 'raw', + selector: 'p', + schema: { + p: { + children: myContentSchema, + }, + }, + } ] ); + const output = { + p: { + children: myContentSchema, + attributes: [ 'id' ], + isMatch: undefined, + }, + }; + expect( + getBlockContentSchema( transforms ) + ).toEqual( output ); + } ); + + it( 'should handle multiple raw transforms', () => { + const preformattedIsMatch = ( input ) => { + return input === 4; + }; + const transforms = deepFreeze( [ { + blockName: 'core/paragraph', + type: 'raw', + schema: { + p: { + children: myContentSchema, + }, + }, + }, { + blockName: 'core/preformatted', + type: 'raw', + isMatch: preformattedIsMatch, + schema: { + pre: { + children: myContentSchema, + }, + }, + } ] ); + const output = { + p: { + children: myContentSchema, + attributes: [ 'id' ], + isMatch: undefined, + }, + pre: { + children: myContentSchema, + attributes: [], + isMatch: preformattedIsMatch, + }, + }; + expect( + getBlockContentSchema( transforms ) + ).toEqual( output ); + } ); + + it( 'should correctly merge the children', () => { + const transforms = deepFreeze( [ { + blockName: 'my/preformatted', + type: 'raw', + schema: { + pre: { + children: { + sub: {}, + sup: {}, + strong: {}, + }, + }, + }, + }, { + blockName: 'core/preformatted', + type: 'raw', + schema: { + pre: { + children: myContentSchema, + }, + }, + } ] ); + const output = { + pre: { + children: { + strong: {}, + em: {}, + sub: {}, + sup: {}, + }, + }, + }; + expect( + getBlockContentSchema( transforms ) + ).toEqual( output ); + } ); + + it( 'should correctly merge the attributes', () => { + const transforms = deepFreeze( [ { + blockName: 'my/preformatted', + type: 'raw', + schema: { + pre: { + attributes: [ 'data-chicken' ], + children: myContentSchema, + }, + }, + }, { + blockName: 'core/preformatted', + type: 'raw', + schema: { + pre: { + attributes: [ 'data-ribs' ], + children: myContentSchema, + }, + }, + } ] ); + const output = { + pre: { + children: myContentSchema, + attributes: [ 'data-chicken', 'data-ribs' ], + }, + }; + expect( + getBlockContentSchema( transforms ) + ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/raw-handling/utils.js b/gb-src/packages/blocks/src/api/raw-handling/utils.js new file mode 100644 index 0000000000000..896bb6b8d4a5e --- /dev/null +++ b/gb-src/packages/blocks/src/api/raw-handling/utils.js @@ -0,0 +1,305 @@ +/** + * External dependencies + */ +import { mapValues, mergeWith, includes, noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { unwrap, insertAfter, remove } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import { hasBlockSupport } from '..'; +import { isPhrasingContent } from './phrasing-content'; + +/** + * Browser dependencies + */ +const { ELEMENT_NODE, TEXT_NODE } = window.Node; + +/** + * Given raw transforms from blocks, merges all schemas into one. + * + * @param {Array} transforms Block transforms, of the `raw` type. + * + * @return {Object} A complete block content schema. + */ +export function getBlockContentSchema( transforms ) { + const schemas = transforms.map( ( { isMatch, blockName, schema } ) => { + const hasAnchorSupport = hasBlockSupport( blockName, 'anchor' ); + // If the block does not has anchor support and the transform does not + // provides an isMatch we can return the schema right away. + if ( ! hasAnchorSupport && ! isMatch ) { + return schema; + } + + return mapValues( schema, ( value ) => { + let attributes = value.attributes || []; + // If the block supports the "anchor" functionality, it needs to keep its ID attribute. + if ( hasAnchorSupport ) { + attributes = [ ...attributes, 'id' ]; + } + return { + ...value, + attributes, + isMatch: isMatch ? isMatch : undefined, + }; + } ); + } ); + + return mergeWith( {}, ...schemas, ( objValue, srcValue, key ) => { + switch ( key ) { + case 'children': { + if ( objValue === '*' || srcValue === '*' ) { + return '*'; + } + + return { ...objValue, ...srcValue }; + } + case 'attributes': + case 'require': { + return [ ...( objValue || [] ), ...( srcValue || [] ) ]; + } + case 'isMatch': { + // If one of the values being merge is undefined (matches everything), + // the result of the merge will be undefined. + if ( ! objValue || ! srcValue ) { + return undefined; + } + // When merging two isMatch functions, the result is a new function + // that returns if one of the source functions returns true. + return ( ...args ) => { + return objValue( ...args ) || srcValue( ...args ); + }; + } + } + } ); +} + +/** + * Recursively checks if an element is empty. An element is not empty if it + * contains text or contains elements with attributes such as images. + * + * @param {Element} element The element to check. + * + * @return {boolean} Wether or not the element is empty. + */ +export function isEmpty( element ) { + if ( ! element.hasChildNodes() ) { + return true; + } + + return Array.from( element.childNodes ).every( ( node ) => { + if ( node.nodeType === TEXT_NODE ) { + return ! node.nodeValue.trim(); + } + + if ( node.nodeType === ELEMENT_NODE ) { + if ( node.nodeName === 'BR' ) { + return true; + } else if ( node.hasAttributes() ) { + return false; + } + + return isEmpty( node ); + } + + return true; + } ); +} + +/** + * Checks wether HTML can be considered plain text. That is, it does not contain + * any elements that are not line breaks. + * + * @param {string} HTML The HTML to check. + * + * @return {boolean} Wether the HTML can be considered plain text. + */ +export function isPlain( HTML ) { + return ! /<(?!br[ />])/i.test( HTML ); +} + +/** + * Given node filters, deeply filters and mutates a NodeList. + * + * @param {NodeList} nodeList The nodeList to filter. + * @param {Array} filters An array of functions that can mutate with the provided node. + * @param {Document} doc The document of the nodeList. + * @param {Object} schema The schema to use. + */ +export function deepFilterNodeList( nodeList, filters, doc, schema ) { + Array.from( nodeList ).forEach( ( node ) => { + deepFilterNodeList( node.childNodes, filters, doc, schema ); + + filters.forEach( ( item ) => { + // Make sure the node is still attached to the document. + if ( ! doc.contains( node ) ) { + return; + } + + item( node, doc, schema ); + } ); + } ); +} + +/** + * Given node filters, deeply filters HTML tags. + * Filters from the deepest nodes to the top. + * + * @param {string} HTML The HTML to filter. + * @param {Array} filters An array of functions that can mutate with the provided node. + * @param {Object} schema The schema to use. + * + * @return {string} The filtered HTML. + */ +export function deepFilterHTML( HTML, filters = [], schema ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = HTML; + + deepFilterNodeList( doc.body.childNodes, filters, doc, schema ); + + return doc.body.innerHTML; +} + +/** + * Given a schema, unwraps or removes nodes, attributes and classes on a node + * list. + * + * @param {NodeList} nodeList The nodeList to filter. + * @param {Document} doc The document of the nodeList. + * @param {Object} schema An array of functions that can mutate with the provided node. + * @param {Object} inline Whether to clean for inline mode. + */ +function cleanNodeList( nodeList, doc, schema, inline ) { + Array.from( nodeList ).forEach( ( node ) => { + const tag = node.nodeName.toLowerCase(); + + // It's a valid child, if the tag exists in the schema without an isMatch + // function, or with an isMatch function that matches the node. + if ( + schema.hasOwnProperty( tag ) && + ( ! schema[ tag ].isMatch || schema[ tag ].isMatch( node ) ) + ) { + if ( node.nodeType === ELEMENT_NODE ) { + const { + attributes = [], + classes = [], + children, + require = [], + allowEmpty, + } = schema[ tag ]; + + // If the node is empty and it's supposed to have children, + // remove the node. + if ( children && ! allowEmpty && isEmpty( node ) ) { + remove( node ); + return; + } + + if ( node.hasAttributes() ) { + // Strip invalid attributes. + Array.from( node.attributes ).forEach( ( { name } ) => { + if ( name !== 'class' && ! includes( attributes, name ) ) { + node.removeAttribute( name ); + } + } ); + + // Strip invalid classes. + // In jsdom-jscore, 'node.classList' can be undefined. + // TODO: Explore patching this in jsdom-jscore. + if ( node.classList && node.classList.length ) { + const mattchers = classes.map( ( item ) => { + if ( typeof item === 'string' ) { + return ( className ) => className === item; + } else if ( item instanceof RegExp ) { + return ( className ) => item.test( className ); + } + + return noop; + } ); + + Array.from( node.classList ).forEach( ( name ) => { + if ( ! mattchers.some( ( isMatch ) => isMatch( name ) ) ) { + node.classList.remove( name ); + } + } ); + + if ( ! node.classList.length ) { + node.removeAttribute( 'class' ); + } + } + } + + if ( node.hasChildNodes() ) { + // Do not filter any content. + if ( children === '*' ) { + return; + } + + // Continue if the node is supposed to have children. + if ( children ) { + // If a parent requires certain children, but it does + // not have them, drop the parent and continue. + if ( require.length && ! node.querySelector( require.join( ',' ) ) ) { + cleanNodeList( node.childNodes, doc, schema, inline ); + unwrap( node ); + // If the node is at the top, phrasing content, and + // contains children that are block content, unwrap + // the node because it is invalid. + } else if ( + node.parentNode.nodeName === 'BODY' && + isPhrasingContent( node ) + ) { + cleanNodeList( node.childNodes, doc, schema, inline ); + + if ( Array.from( node.childNodes ).some( ( child ) => ! isPhrasingContent( child ) ) ) { + unwrap( node ); + } + } else { + cleanNodeList( node.childNodes, doc, children, inline ); + } + // Remove children if the node is not supposed to have any. + } else { + while ( node.firstChild ) { + remove( node.firstChild ); + } + } + } + } + // Invalid child. Continue with schema at the same place and unwrap. + } else { + cleanNodeList( node.childNodes, doc, schema, inline ); + + // For inline mode, insert a line break when unwrapping nodes that + // are not phrasing content. + if ( inline && ! isPhrasingContent( node ) && node.nextElementSibling ) { + insertAfter( doc.createElement( 'br' ), node ); + } + + unwrap( node ); + } + } ); +} + +/** + * Given a schema, unwraps or removes nodes, attributes and classes on HTML. + * + * @param {string} HTML The HTML to clean up. + * @param {Object} schema Schema for the HTML. + * @param {Object} inline Whether to clean for inline mode. + * + * @return {string} The cleaned up HTML. + */ +export function removeInvalidHTML( HTML, schema, inline ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = HTML; + + cleanNodeList( doc.body.childNodes, doc, schema, inline ); + + return doc.body.innerHTML; +} diff --git a/gb-src/packages/blocks/src/api/registration.js b/gb-src/packages/blocks/src/api/registration.js new file mode 100644 index 0000000000000..024f9e9d55a3e --- /dev/null +++ b/gb-src/packages/blocks/src/api/registration.js @@ -0,0 +1,438 @@ +/* eslint no-console: [ 'error', { allow: [ 'error' ] } ] */ + +/** + * External dependencies + */ +import { + get, + omit, + pick, + isFunction, + isPlainObject, + some, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { isValidIcon, normalizeIconObject } from './utils'; +import { DEPRECATED_ENTRY_KEYS } from './constants'; + +/** + * Render behavior of a block type icon; one of a Dashicon slug, an element, + * or a component. + * + * @typedef {(string|WPElement|WPComponent)} WPBlockTypeIconRender + * + * @see https://developer.wordpress.org/resource/dashicons/ + */ + +/** + * An object describing a normalized block type icon. + * + * @typedef {Object} WPBlockTypeIconDescriptor + * + * @property {WPBlockTypeIconRender} src Render behavior of the icon, + * one of a Dashicon slug, an + * element, or a component. + * @property {string} background Optimal background hex string + * color when displaying icon. + * @property {string} foreground Optimal foreground hex string + * color when displaying icon. + * @property {string} shadowColor Optimal shadow hex string + * color when displaying icon. + */ + +/** + * Value to use to render the icon for a block type in an editor interface, + * either a Dashicon slug, an element, a component, or an object describing + * the icon. + * + * @typedef {(WPBlockTypeIconDescriptor|WPBlockTypeIconRender)} WPBlockTypeIcon + */ + +/** + * Defined behavior of a block type. + * + * @typedef {Object} WPBlockType + * + * @property {string} name Block type's namespaced name. + * @property {string} title Human-readable block type label. + * @property {string} category Block type category classification, + * used in search interfaces to arrange + * block types by category. + * @property {WPBlockTypeIcon} [icon] Block type icon. + * @property {string[]} [keywords] Additional keywords to produce block + * type as result in search interfaces. + * @property {Object} [attributes] Block type attributes. + * @property {WPComponent} [save] Optional component describing + * serialized markup structure of a + * block type. + * @property {WPComponent} edit Component rendering an element to + * manipulate the attributes of a block + * in the context of an editor. + */ + +/** + * Default values to assign for omitted optional block type settings. + * + * @type {Object} + */ +export const DEFAULT_BLOCK_TYPE_SETTINGS = { + icon: 'block-default', + attributes: {}, + keywords: [], + save: () => null, +}; + +export let serverSideBlockDefinitions = {}; + +/** + * Sets the server side block definition of blocks. + * + * @param {Object} definitions Server-side block definitions + */ +export function unstable__bootstrapServerSideBlockDefinitions( definitions ) { // eslint-disable-line camelcase + serverSideBlockDefinitions = { + ...serverSideBlockDefinitions, + ...definitions, + }; +} + +/** + * Registers a new block provided a unique name and an object defining its + * behavior. Once registered, the block is made available as an option to any + * editor interface where blocks are implemented. + * + * @param {string} name Block name. + * @param {Object} settings Block settings. + * + * @return {?WPBlock} The block, if it has been successfully registered; + * otherwise `undefined`. + */ +export function registerBlockType( name, settings ) { + settings = { + name, + ...DEFAULT_BLOCK_TYPE_SETTINGS, + ...get( serverSideBlockDefinitions, name ), + ...settings, + }; + + if ( typeof name !== 'string' ) { + console.error( + 'Block names must be strings.' + ); + return; + } + if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( name ) ) { + console.error( + 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' + ); + return; + } + if ( select( 'core/blocks' ).getBlockType( name ) ) { + console.error( + 'Block "' + name + '" is already registered.' + ); + return; + } + + const preFilterSettings = { ...settings }; + settings = applyFilters( 'blocks.registerBlockType', settings, name ); + + if ( settings.deprecated ) { + settings.deprecated = settings.deprecated.map( ( deprecation ) => + pick( // Only keep valid deprecation keys. + applyFilters( + 'blocks.registerBlockType', + // Merge deprecation keys with pre-filter settings + // so that filters that depend on specific keys being + // present don't fail. + { + // Omit deprecation keys here so that deprecations + // can opt out of specific keys like "supports". + ...omit( preFilterSettings, DEPRECATED_ENTRY_KEYS ), + ...deprecation, + }, + name + ), + DEPRECATED_ENTRY_KEYS + ) + ); + } + + if ( ! isPlainObject( settings ) ) { + console.error( + 'Block settings must be a valid object.' + ); + return; + } + + if ( ! isFunction( settings.save ) ) { + console.error( + 'The "save" property must be a valid function.' + ); + return; + } + if ( 'edit' in settings && ! isFunction( settings.edit ) ) { + console.error( + 'The "edit" property must be a valid function.' + ); + return; + } + if ( ! ( 'category' in settings ) ) { + console.error( + 'The block "' + name + '" must have a category.' + ); + return; + } + if ( + 'category' in settings && + ! some( select( 'core/blocks' ).getCategories(), { slug: settings.category } ) + ) { + console.error( + 'The block "' + name + '" must have a registered category.' + ); + return; + } + if ( ! ( 'title' in settings ) || settings.title === '' ) { + console.error( + 'The block "' + name + '" must have a title.' + ); + return; + } + if ( typeof settings.title !== 'string' ) { + console.error( + 'Block titles must be strings.' + ); + return; + } + + settings.icon = normalizeIconObject( settings.icon ); + if ( ! isValidIcon( settings.icon.src ) ) { + console.error( + 'The icon passed is invalid. ' + + 'The icon should be a string, an element, a function, or an object following the specifications documented in https://developer.wordpress.org/block-editor/developers/block-api/block-registration/#icon-optional' + ); + return; + } + + dispatch( 'core/blocks' ).addBlockTypes( settings ); + + return settings; +} + +/** + * Unregisters a block. + * + * @param {string} name Block name. + * + * @return {?WPBlock} The previous block value, if it has been successfully + * unregistered; otherwise `undefined`. + */ +export function unregisterBlockType( name ) { + const oldBlock = select( 'core/blocks' ).getBlockType( name ); + if ( ! oldBlock ) { + console.error( + 'Block "' + name + '" is not registered.' + ); + return; + } + dispatch( 'core/blocks' ).removeBlockTypes( name ); + return oldBlock; +} + +/** + * Assigns name of block for handling non-block content. + * + * @param {string} blockName Block name. + */ +export function setFreeformContentHandlerName( blockName ) { + dispatch( 'core/blocks' ).setFreeformFallbackBlockName( blockName ); +} + +/** + * Retrieves name of block handling non-block content, or undefined if no + * handler has been defined. + * + * @return {?string} Block name. + */ +export function getFreeformContentHandlerName() { + return select( 'core/blocks' ).getFreeformFallbackBlockName(); +} + +/** + * Retrieves name of block used for handling grouping interactions. + * + * @return {?string} Block name. + */ +export function getGroupingBlockName() { + return select( 'core/blocks' ).getGroupingBlockName(); +} + +/** + * Assigns name of block handling unregistered block types. + * + * @param {string} blockName Block name. + */ +export function setUnregisteredTypeHandlerName( blockName ) { + dispatch( 'core/blocks' ).setUnregisteredFallbackBlockName( blockName ); +} + +/** + * Retrieves name of block handling unregistered block types, or undefined if no + * handler has been defined. + * + * @return {?string} Block name. + */ +export function getUnregisteredTypeHandlerName() { + return select( 'core/blocks' ).getUnregisteredFallbackBlockName(); +} + +/** + * Assigns the default block name. + * + * @param {string} name Block name. + */ +export function setDefaultBlockName( name ) { + dispatch( 'core/blocks' ).setDefaultBlockName( name ); +} + +/** + * Assigns name of block for handling block grouping interactions. + * + * @param {string} name Block name. + */ +export function setGroupingBlockName( name ) { + dispatch( 'core/blocks' ).setGroupingBlockName( name ); +} + +/** + * Retrieves the default block name. + * + * @return {?string} Block name. + */ +export function getDefaultBlockName() { + return select( 'core/blocks' ).getDefaultBlockName(); +} + +/** + * Returns a registered block type. + * + * @param {string} name Block name. + * + * @return {?Object} Block type. + */ +export function getBlockType( name ) { + return select( 'core/blocks' ).getBlockType( name ); +} + +/** + * Returns all registered blocks. + * + * @return {Array} Block settings. + */ +export function getBlockTypes() { + return select( 'core/blocks' ).getBlockTypes(); +} + +/** + * Returns the block support value for a feature, if defined. + * + * @param {(string|Object)} nameOrType Block name or type object + * @param {string} feature Feature to retrieve + * @param {*} defaultSupports Default value to return if not + * explicitly defined + * + * @return {?*} Block support value + */ +export function getBlockSupport( nameOrType, feature, defaultSupports ) { + return select( 'core/blocks' ).getBlockSupport( nameOrType, feature, defaultSupports ); +} + +/** + * Returns true if the block defines support for a feature, or false otherwise. + * + * @param {(string|Object)} nameOrType Block name or type object. + * @param {string} feature Feature to test. + * @param {boolean} defaultSupports Whether feature is supported by + * default if not explicitly defined. + * + * @return {boolean} Whether block supports feature. + */ +export function hasBlockSupport( nameOrType, feature, defaultSupports ) { + return select( 'core/blocks' ).hasBlockSupport( nameOrType, feature, defaultSupports ); +} + +/** + * Determines whether or not the given block is a reusable block. This is a + * special block type that is used to point to a global block stored via the + * API. + * + * @param {Object} blockOrType Block or Block Type to test. + * + * @return {boolean} Whether the given block is a reusable block. + */ +export function isReusableBlock( blockOrType ) { + return blockOrType.name === 'core/block'; +} + +/** + * Returns an array with the child blocks of a given block. + * + * @param {string} blockName Name of block (example: “latest-posts”). + * + * @return {Array} Array of child block names. + */ +export const getChildBlockNames = ( blockName ) => { + return select( 'core/blocks' ).getChildBlockNames( blockName ); +}; + +/** + * Returns a boolean indicating if a block has child blocks or not. + * + * @param {string} blockName Name of block (example: “latest-posts”). + * + * @return {boolean} True if a block contains child blocks and false otherwise. + */ +export const hasChildBlocks = ( blockName ) => { + return select( 'core/blocks' ).hasChildBlocks( blockName ); +}; + +/** + * Returns a boolean indicating if a block has at least one child block with inserter support. + * + * @param {string} blockName Block type name. + * + * @return {boolean} True if a block contains at least one child blocks with inserter support + * and false otherwise. + */ +export const hasChildBlocksWithInserterSupport = ( blockName ) => { + return select( 'core/blocks' ).hasChildBlocksWithInserterSupport( blockName ); +}; + +/** + * Registers a new block style variation for the given block. + * + * @param {string} blockName Name of block (example: “core/latest-posts”). + * @param {Object} styleVariation Object containing `name` which is the class name applied to the block and `label` which identifies the variation to the user. + */ +export const registerBlockStyle = ( blockName, styleVariation ) => { + dispatch( 'core/blocks' ).addBlockStyles( blockName, styleVariation ); +}; + +/** + * Unregisters a block style variation for the given block. + * + * @param {string} blockName Name of block (example: “core/latest-posts”). + * @param {string} styleVariationName Name of class applied to the block. + */ +export const unregisterBlockStyle = ( blockName, styleVariationName ) => { + dispatch( 'core/blocks' ).removeBlockStyles( blockName, styleVariationName ); +}; diff --git a/gb-src/packages/blocks/src/api/serializer.js b/gb-src/packages/blocks/src/api/serializer.js new file mode 100644 index 0000000000000..1776a32e36299 --- /dev/null +++ b/gb-src/packages/blocks/src/api/serializer.js @@ -0,0 +1,297 @@ +/** + * External dependencies + */ +import { isEmpty, reduce, isObject, castArray, startsWith } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, cloneElement, renderToString } from '@wordpress/element'; +import { hasFilter, applyFilters } from '@wordpress/hooks'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import { + getBlockType, + getFreeformContentHandlerName, + getUnregisteredTypeHandlerName, +} from './registration'; +import { normalizeBlockType } from './utils'; +import BlockContentProvider from '../block-content-provider'; + +/** + * @typedef {Object} WPBlockSerializationOptions Serialization Options. + * + * @property {boolean} isInnerBlocks Whether we are serializing inner blocks. + */ + +/** + * Returns the block's default classname from its name. + * + * @param {string} blockName The block name. + * + * @return {string} The block's default class. + */ +export function getBlockDefaultClassName( blockName ) { + // Generated HTML classes for blocks follow the `wp-block-{name}` nomenclature. + // Blocks provided by WordPress drop the prefixes 'core/' or 'core-' (used in 'core-embed/'). + const className = 'wp-block-' + blockName.replace( /\//, '-' ).replace( /^core-/, '' ); + + return applyFilters( 'blocks.getBlockDefaultClassName', className, blockName ); +} + +/** + * Returns the block's default menu item classname from its name. + * + * @param {string} blockName The block name. + * + * @return {string} The block's default menu item class. + */ +export function getBlockMenuDefaultClassName( blockName ) { + // Generated HTML classes for blocks follow the `editor-block-list-item-{name}` nomenclature. + // Blocks provided by WordPress drop the prefixes 'core/' or 'core-' (used in 'core-embed/'). + const className = 'editor-block-list-item-' + blockName.replace( /\//, '-' ).replace( /^core-/, '' ); + + return applyFilters( 'blocks.getBlockMenuDefaultClassName', className, blockName ); +} + +/** + * Given a block type containing a save render implementation and attributes, returns the + * enhanced element to be saved or string when raw HTML expected. + * + * @param {string|Object} blockTypeOrName Block type or name. + * @param {Object} attributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. + * + * @return {Object|string} Save element or raw HTML string. + */ +export function getSaveElement( blockTypeOrName, attributes, innerBlocks = [] ) { + const blockType = normalizeBlockType( blockTypeOrName ); + let { save } = blockType; + + // Component classes are unsupported for save since serialization must + // occur synchronously. For improved interoperability with higher-order + // components which often return component class, emulate basic support. + if ( save.prototype instanceof Component ) { + const instance = new save( { attributes } ); + save = instance.render.bind( instance ); + } + + let element = save( { attributes, innerBlocks } ); + + if ( isObject( element ) && hasFilter( 'blocks.getSaveContent.extraProps' ) ) { + /** + * Filters the props applied to the block save result element. + * + * @param {Object} props Props applied to save element. + * @param {WPBlockType} blockType Block type definition. + * @param {Object} attributes Block attributes. + */ + const props = applyFilters( + 'blocks.getSaveContent.extraProps', + { ...element.props }, + blockType, + attributes + ); + + if ( ! isShallowEqual( props, element.props ) ) { + element = cloneElement( element, props ); + } + } + + /** + * Filters the save result of a block during serialization. + * + * @param {WPElement} element Block save result. + * @param {WPBlockType} blockType Block type definition. + * @param {Object} attributes Block attributes. + */ + element = applyFilters( 'blocks.getSaveElement', element, blockType, attributes ); + + return ( + <BlockContentProvider innerBlocks={ innerBlocks }> + { element } + </BlockContentProvider> + ); +} + +/** + * Given a block type containing a save render implementation and attributes, returns the + * static markup to be saved. + * + * @param {string|Object} blockTypeOrName Block type or name. + * @param {Object} attributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. + * + * @return {string} Save content. + */ +export function getSaveContent( blockTypeOrName, attributes, innerBlocks ) { + const blockType = normalizeBlockType( blockTypeOrName ); + + return renderToString( getSaveElement( blockType, attributes, innerBlocks ) ); +} + +/** + * Returns attributes which are to be saved and serialized into the block + * comment delimiter. + * + * When a block exists in memory it contains as its attributes both those + * parsed the block comment delimiter _and_ those which matched from the + * contents of the block. + * + * This function returns only those attributes which are needed to persist and + * which cannot be matched from the block content. + * + * @param {Object<string,*>} blockType Block type. + * @param {Object<string,*>} attributes Attributes from in-memory block data. + * + * @return {Object<string,*>} Subset of attributes for comment serialization. + */ +export function getCommentAttributes( blockType, attributes ) { + return reduce( blockType.attributes, ( result, attributeSchema, key ) => { + const value = attributes[ key ]; + + // Ignore undefined values. + if ( undefined === value ) { + return result; + } + + // Ignore all attributes but the ones with an "undefined" source + // "undefined" source refers to attributes saved in the block comment. + if ( attributeSchema.source !== undefined ) { + return result; + } + + // Ignore default value. + if ( 'default' in attributeSchema && attributeSchema.default === value ) { + return result; + } + + // Otherwise, include in comment set. + result[ key ] = value; + return result; + }, {} ); +} + +/** + * Given an attributes object, returns a string in the serialized attributes + * format prepared for post content. + * + * @param {Object} attributes Attributes object. + * + * @return {string} Serialized attributes. + */ +export function serializeAttributes( attributes ) { + return JSON.stringify( attributes ) + // Don't break HTML comments. + .replace( /--/g, '\\u002d\\u002d' ) + + // Don't break non-standard-compliant tools. + .replace( /</g, '\\u003c' ) + .replace( />/g, '\\u003e' ) + .replace( /&/g, '\\u0026' ) + + // Bypass server stripslashes behavior which would unescape stringify's + // escaping of quotation mark. + // + // See: https://developer.wordpress.org/reference/functions/wp_kses_stripslashes/ + .replace( /\\"/g, '\\u0022' ); +} + +/** + * Given a block object, returns the Block's Inner HTML markup. + * + * @param {Object} block Block instance. + * + * @return {string} HTML. + */ +export function getBlockContent( block ) { + // @todo why not getBlockInnerHtml? + + // If block was parsed as invalid or encounters an error while generating + // save content, use original content instead to avoid content loss. If a + // block contains nested content, exempt it from this condition because we + // otherwise have no access to its original content and content loss would + // still occur. + let saveContent = block.originalContent; + if ( block.isValid || block.innerBlocks.length ) { + try { + saveContent = getSaveContent( block.name, block.attributes, block.innerBlocks ); + } catch ( error ) {} + } + + return saveContent; +} + +/** + * Returns the content of a block, including comment delimiters. + * + * @param {string} rawBlockName Block name. + * @param {Object} attributes Block attributes. + * @param {string} content Block save content. + * + * @return {string} Comment-delimited block content. + */ +export function getCommentDelimitedContent( rawBlockName, attributes, content ) { + const serializedAttributes = ! isEmpty( attributes ) ? + serializeAttributes( attributes ) + ' ' : + ''; + + // Strip core blocks of their namespace prefix. + const blockName = startsWith( rawBlockName, 'core/' ) ? + rawBlockName.slice( 5 ) : + rawBlockName; + + // @todo make the `wp:` prefix potentially configurable. + + if ( ! content ) { + return `<!-- wp:${ blockName } ${ serializedAttributes }/-->`; + } + + return ( + `<!-- wp:${ blockName } ${ serializedAttributes }-->\n` + + content + + `\n<!-- /wp:${ blockName } -->` + ); +} + +/** + * Returns the content of a block, including comment delimiters, determining + * serialized attributes and content form from the current state of the block. + * + * @param {Object} block Block instance. + * @param {WPBlockSerializationOptions} options Serialization options. + * + * @return {string} Serialized block. + */ +export function serializeBlock( block, { isInnerBlocks = false } = {} ) { + const blockName = block.name; + const saveContent = getBlockContent( block ); + + if ( + ( blockName === getUnregisteredTypeHandlerName() ) || + ( ! isInnerBlocks && blockName === getFreeformContentHandlerName() ) + ) { + return saveContent; + } + + const blockType = getBlockType( blockName ); + const saveAttributes = getCommentAttributes( blockType, block.attributes ); + return getCommentDelimitedContent( blockName, saveAttributes, saveContent ); +} + +/** + * Takes a block or set of blocks and returns the serialized post content. + * + * @param {Array} blocks Block(s) to serialize. + * @param {WPBlockSerializationOptions} options Serialization options. + * + * @return {string} The post content. + */ +export default function serialize( blocks, options ) { + return castArray( blocks ) + .map( ( block ) => serializeBlock( block, options ) ) + .join( '\n\n' ); +} diff --git a/gb-src/packages/blocks/src/api/templates.js b/gb-src/packages/blocks/src/api/templates.js new file mode 100644 index 0000000000000..d09be49453e70 --- /dev/null +++ b/gb-src/packages/blocks/src/api/templates.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import { every, map, get, mapValues, isArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { createBlock } from './factory'; +import { getBlockType } from './registration'; + +/** + * Checks whether a list of blocks matches a template by comparing the block names. + * + * @param {Array} blocks Block list. + * @param {Array} template Block template. + * + * @return {boolean} Whether the list of blocks matches a templates + */ +export function doBlocksMatchTemplate( blocks = [], template = [] ) { + return ( + blocks.length === template.length && + every( template, ( [ name, , innerBlocksTemplate ], index ) => { + const block = blocks[ index ]; + return ( + name === block.name && + doBlocksMatchTemplate( block.innerBlocks, innerBlocksTemplate ) + ); + } ) + ); +} + +/** + * Synchronize a block list with a block template. + * + * Synchronizing a block list with a block template means that we loop over the blocks + * keep the block as is if it matches the block at the same position in the template + * (If it has the same name) and if doesn't match, we create a new block based on the template. + * Extra blocks not present in the template are removed. + * + * @param {Array} blocks Block list. + * @param {Array} template Block template. + * + * @return {Array} Updated Block list. + */ +export function synchronizeBlocksWithTemplate( blocks = [], template ) { + // If no template is provided, return blocks unmodified. + if ( ! template ) { + return blocks; + } + + return map( template, ( [ name, attributes, innerBlocksTemplate ], index ) => { + const block = blocks[ index ]; + + if ( block && block.name === name ) { + const innerBlocks = synchronizeBlocksWithTemplate( block.innerBlocks, innerBlocksTemplate ); + return { ...block, innerBlocks }; + } + + // To support old templates that were using the "children" format + // for the attributes using "html" strings now, we normalize the template attributes + // before creating the blocks. + + const blockType = getBlockType( name ); + const isHTMLAttribute = ( attributeDefinition ) => get( attributeDefinition, [ 'source' ] ) === 'html'; + const isQueryAttribute = ( attributeDefinition ) => get( attributeDefinition, [ 'source' ] ) === 'query'; + + const normalizeAttributes = ( schema, values ) => { + return mapValues( values, ( value, key ) => { + return normalizeAttribute( schema[ key ], value ); + } ); + }; + const normalizeAttribute = ( definition, value ) => { + if ( isHTMLAttribute( definition ) && isArray( value ) ) { + // Introduce a deprecated call at this point + // When we're confident that "children" format should be removed from the templates. + + return renderToString( value ); + } + + if ( isQueryAttribute( definition ) && value ) { + return value.map( ( subValues ) => { + return normalizeAttributes( definition.query, subValues ); + } ); + } + + return value; + }; + + const normalizedAttributes = normalizeAttributes( + get( blockType, [ 'attributes' ], {} ), + attributes + ); + + return createBlock( + name, + normalizedAttributes, + synchronizeBlocksWithTemplate( [], innerBlocksTemplate ) + ); + } ); +} diff --git a/gb-src/packages/blocks/src/api/test/children.js b/gb-src/packages/blocks/src/api/test/children.js new file mode 100644 index 0000000000000..cc3fe5d76d28a --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/children.js @@ -0,0 +1,137 @@ +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + getSerializeCapableElement, + concat, + toHTML, + fromDOM, +} from '../children'; + +describe( 'getSerializeCapableElement', () => { + it( 'returns a serialize capable element', () => { + const blockNode = [ + 'This ', + { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'is' ], + }, + }, + ' a test', + ]; + + const element = getSerializeCapableElement( blockNode ); + + // Intentionally avoid introspecting the shape of the generated element + // since all that is cared about is that it can be serialized. + const html = renderToString( element ); + + expect( html ).toBe( 'This <strong class="is-extra-strong">is</strong> a test' ); + } ); +} ); + +describe( 'concat', () => { + it( 'should combine two or more sets of block nodes', () => { + const result = concat( + { + type: 'strong', + props: { + children: [ 'Hello' ], + }, + }, + ' ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + ); + + expect( result ).toEqual( [ + { + type: 'strong', + props: { + children: [ 'Hello' ], + }, + }, + ' ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + ] ); + } ); + + it( 'should merge adjacent strings', () => { + const result = concat( + 'Hello', + ' ', + { + type: 'strong', + props: { + children: [ 'World' ], + }, + }, + ); + + expect( result ).toEqual( [ + 'Hello ', + { + type: 'strong', + props: { + children: [ 'World' ], + }, + }, + ] ); + } ); +} ); + +describe( 'toHTML', () => { + it( 'should convert a children array of block nodes to its equivalent html string', () => { + const children = [ + 'This is a ', + { + type: 'strong', + props: { + children: [ 'test' ], + }, + }, + '!', + ]; + + const html = toHTML( children ); + + expect( html ).toBe( 'This is a <strong>test</strong>!' ); + } ); +} ); + +describe( 'fromDOM', () => { + it( 'should return an equivalent block children', () => { + const node = document.createElement( 'div' ); + node.innerHTML = 'This <strong class="is-extra-strong">is</strong> a test'; + + const blockNode = fromDOM( node.childNodes ); + + expect( blockNode ).toEqual( [ + 'This ', + { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'is' ], + }, + }, + ' a test', + ] ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/factory.js b/gb-src/packages/blocks/src/api/test/factory.js new file mode 100644 index 0000000000000..bb34cb60cf2cd --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/factory.js @@ -0,0 +1,1631 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; +import { noop, times } from 'lodash'; + +/** + * Internal dependencies + */ +import { + createBlock, + cloneBlock, + getPossibleBlockTransformations, + switchToBlockType, + getBlockTransforms, + findTransform, + isWildcardBlockTransform, + isContainerGroupBlock, + isBlockSelectionOfSameType, +} from '../factory'; +import { + getBlockType, + getBlockTypes, + registerBlockType, + unregisterBlockType, + setGroupingBlockName, +} from '../registration'; + +describe( 'block factory', () => { + const defaultBlockSettings = { + attributes: { + value: { + type: 'string', + }, + }, + save: noop, + category: 'common', + title: 'block title', + }; + + beforeAll( () => { + // Load blocks store + require( '../../store' ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'createBlock()', () => { + it( 'should create a block given its blockType, attributes, inner blocks', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + includesDefault: { + type: 'boolean', + default: true, + }, + includesFalseyDefault: { + type: 'number', + default: 0, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = createBlock( + 'core/test-block', + { align: 'left' }, + [ createBlock( 'core/test-block' ) ], + ); + + expect( block.name ).toEqual( 'core/test-block' ); + expect( block.attributes ).toEqual( { + includesDefault: true, + includesFalseyDefault: 0, + align: 'left', + } ); + expect( block.isValid ).toBe( true ); + expect( block.innerBlocks ).toHaveLength( 1 ); + expect( block.innerBlocks[ 0 ].name ).toBe( 'core/test-block' ); + expect( typeof block.clientId ).toBe( 'string' ); + } ); + + it( 'should cast children and node source attributes with default undefined', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( block.attributes ).toEqual( { + content: [], + } ); + } ); + + it( 'should cast children and node source attributes with string as default', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + default: 'test', + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( block.attributes ).toEqual( { + content: [ 'test' ], + } ); + } ); + + it( 'should cast children and node source attributes with unknown type as default', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + default: 1, + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( block.attributes ).toEqual( { + content: [], + } ); + } ); + + it( 'should cast rich-text source attributes', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + source: 'html', + }, + }, + } ); + + const block = createBlock( 'core/test-block', { + content: 'test', + } ); + + expect( block.attributes ).toEqual( { + content: 'test', + } ); + } ); + } ); + + describe( 'cloneBlock()', () => { + it( 'should merge attributes into the existing block', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + isDifferent: { + type: 'boolean', + default: false, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = deepFreeze( + createBlock( + 'core/test-block', + { align: 'left' }, + [ createBlock( 'core/test-block' ) ], + ) + ); + + const clonedBlock = cloneBlock( block, { + isDifferent: true, + } ); + + expect( clonedBlock.name ).toEqual( block.name ); + expect( clonedBlock.attributes ).toEqual( { + align: 'left', + isDifferent: true, + } ); + expect( clonedBlock.innerBlocks ).toHaveLength( 1 ); + expect( typeof clonedBlock.clientId ).toBe( 'string' ); + expect( clonedBlock.clientId ).not.toBe( block.clientId ); + } ); + + it( 'should replace inner blocks of the existing block', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + isDifferent: { + type: 'boolean', + default: false, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = deepFreeze( + createBlock( + 'core/test-block', + { align: 'left' }, + [ + createBlock( 'core/test-block', { align: 'right' } ), + createBlock( 'core/test-block', { align: 'left' } ), + ], + ) + ); + + const clonedBlock = cloneBlock( block, undefined, [ + createBlock( 'core/test-block' ), + ] ); + + expect( clonedBlock.innerBlocks ).toHaveLength( 1 ); + expect( clonedBlock.innerBlocks[ 0 ].attributes ).not.toHaveProperty( 'align' ); + } ); + + it( 'should clone innerBlocks if innerBlocks are not passed', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + isDifferent: { + type: 'boolean', + default: false, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = deepFreeze( + createBlock( + 'core/test-block', + { align: 'left' }, + [ + createBlock( 'core/test-block', { align: 'right' } ), + createBlock( 'core/test-block', { align: 'left' } ), + ], + ) + ); + + const clonedBlock = cloneBlock( block ); + + expect( clonedBlock.innerBlocks ).toHaveLength( 2 ); + expect( clonedBlock.innerBlocks[ 0 ].clientId ).not.toBe( block.innerBlocks[ 0 ].clientId ); + expect( clonedBlock.innerBlocks[ 0 ].attributes ).not.toBe( block.innerBlocks[ 0 ].attributes ); + expect( clonedBlock.innerBlocks[ 0 ].attributes ).toEqual( block.innerBlocks[ 0 ].attributes ); + expect( clonedBlock.innerBlocks[ 1 ].clientId ).not.toBe( block.innerBlocks[ 1 ].clientId ); + expect( clonedBlock.innerBlocks[ 1 ].attributes ).not.toBe( block.innerBlocks[ 1 ].attributes ); + expect( clonedBlock.innerBlocks[ 1 ].attributes ).toEqual( block.innerBlocks[ 1 ].attributes ); + } ); + } ); + + describe( 'getPossibleBlockTransformations()', () => { + it( 'should should show as available a simple "from" transformation"', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + } ); + + it( 'should show as available a simple "to" transformation"', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' ); + } ); + + it( 'should not show a transformation if multiple blocks are passed and the transformation is not multi block (for a "from" transform)', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block1 = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const block2 = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block1, block2 ] ); + + expect( availableBlocks ).toEqual( [] ); + } ); + + it( 'should not show a transformation if multiple blocks are passed and the transformation is not multi block (for a "to" transform)', () => { + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/updated-text-block' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + + const block1 = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const block2 = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block1, block2 ] ); + + expect( availableBlocks ).toEqual( [] ); + } ); + + it( 'should show a transformation as available if multiple blocks are passed and the transformation accepts multiple blocks (for a "from" transform)', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMultiBlock: true, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block1 = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const block2 = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block1, block2 ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + } ); + + it( 'should show a transformation as available if multiple blocks are passed and the transformation accepts multiple blocks (for a "to" transform)', () => { + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/updated-text-block' ], + transform: noop, + isMultiBlock: true, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + + const block1 = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const block2 = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block1, block2 ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + } ); + + it( 'should show multiple possible transformations', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMultiBlock: true, + }, { + type: 'block', + blocks: [ 'core/another-text-block' ], + transform: noop, + isMultiBlock: true, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/another-text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 2 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' ); + expect( availableBlocks[ 1 ].name ).toBe( 'core/another-text-block' ); + } ); + + it( 'should show multiple possible transformations when multiple blocks have a matching `from` transform', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMultiBlock: false, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/another-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMultiBlock: true, + } ], + }, + save: noop, + category: 'common', + title: 'another text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 2 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + expect( availableBlocks[ 1 ].name ).toBe( 'core/another-text-block' ); + } ); + + it( 'should show multiple possible transformations for a single `to` transform object with multiple block names', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block', 'core/another-text-block' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/another-text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 2 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' ); + expect( availableBlocks[ 1 ].name ).toBe( 'core/another-text-block' ); + } ); + + it( 'returns a single transformation for a "from" transform that has a `isMatch` function returning `true`', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMatch: () => true, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + } ); + + it( 'returns no transformations for a "from" transform with a `isMatch` function returning `false`', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMatch: () => false, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'chicken', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toEqual( [] ); + } ); + + it( 'returns a single transformation for a "to" transform that has a `isMatch` function returning `true`', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMatch: () => true, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' ); + } ); + + it( 'returns no transformations for a "to" transform with a `isMatch` function returning `false`', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMatch: () => false, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ block ] ); + + expect( availableBlocks ).toEqual( [] ); + } ); + + it( 'for a non multiblock transform, the isMatch function receives the source block’s attributes object as its first argument', () => { + const isMatch = jest.fn(); + + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMatch, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + getPossibleBlockTransformations( [ block ] ); + + expect( isMatch ).toHaveBeenCalledWith( { value: 'ribs' } ); + } ); + + it( 'for a multiblock transform, the isMatch function receives an array containing every source block’s attributes as its first argument', () => { + const isMatch = jest.fn(); + + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: noop, + isMultiBlock: true, + isMatch, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const meatBlock = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + const cheeseBlock = createBlock( 'core/updated-text-block', { + value: 'halloumi', + } ); + + getPossibleBlockTransformations( [ meatBlock, cheeseBlock ] ); + + expect( isMatch ).toHaveBeenCalledWith( [ { value: 'ribs' }, { value: 'halloumi' } ] ); + } ); + + describe( 'wildcard block transforms', () => { + beforeEach( () => { + registerBlockType( 'core/group', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'A block that groups other blocks.', + } ); + } ); + + it( 'should should show wildcard "from" transformation as available for multiple blocks of the same type', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/image-block', defaultBlockSettings ); + + const textBlocks = times( 4, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( textBlocks ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + + it( 'should should show wildcard "from" transformation as available for multiple blocks of different types', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/image-block', defaultBlockSettings ); + + const textBlocks = times( 2, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const imageBlocks = times( 2, ( index ) => { + return createBlock( 'core/image-block', { + value: `imageBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( [ ...textBlocks, ...imageBlocks ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + + it( 'should should show wildcard "from" transformation as available for single blocks', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const blocks = times( 1, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( blocks ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + } ); + } ); + + describe( 'switchToBlockType()', () => { + it( 'should switch the blockType of a block using the "transform form"', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/text-block' ], + transform: ( { value } ) => { + return createBlock( 'core/updated-text-block', { + value: 'chicken ' + value, + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ] ).toHaveProperty( 'clientId' ); + expect( transformedBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + expect( transformedBlocks[ 0 ].isValid ).toBe( true ); + expect( transformedBlocks[ 0 ].attributes ).toEqual( { + value: 'chicken ribs', + } ); + } ); + + it( 'should switch the blockType of a block using the "transform to"', () => { + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/updated-text-block' ], + transform: ( { value } ) => { + return createBlock( 'core/updated-text-block', { + value: 'chicken ' + value, + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'text-block', + } ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ] ).toHaveProperty( 'clientId' ); + expect( transformedBlocks[ 0 ].name ).toBe( 'core/updated-text-block' ); + expect( transformedBlocks[ 0 ].isValid ).toBe( true ); + expect( transformedBlocks[ 0 ].attributes ).toEqual( { + value: 'chicken ribs', + } ); + } ); + + it( 'should return null if no transformation is found', () => { + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject transformations that return null', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + transform: () => null, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject transformations that return an empty array', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + transform: () => [], + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject single transformations that do not include block types', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + transform: ( { value } ) => { + return { + attributes: { + value: 'chicken ' + value, + }, + }; + }, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject array transformations that do not include block types', () => { + registerBlockType( 'core/updated-text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + transform: ( { value } ) => { + return [ + createBlock( 'core/updated-text-block', { + value: 'chicken ' + value, + } ), + { + attributes: { + value: 'smoked ' + value, + }, + }, + ]; + }, + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject single transformations with unexpected block types', () => { + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + blocks: [ 'core/updated-text-block' ], + transform: ( { value } ) => { + return createBlock( 'core/text-block', { + value: 'chicken ' + value, + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'text block', + } ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toBeNull(); + } ); + + it( 'should reject array transformations with unexpected block types', () => { + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + blocks: [ 'core/updated-text-block' ], + transform: ( { value } ) => { + return [ + createBlock( 'core/text-block', { + value: 'chicken ' + value, + } ), + createBlock( 'core/text-block', { + value: 'smoked ' + value, + } ), + ]; + }, + } ], + }, + save: noop, + category: 'common', + title: 'text block', + } ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + expect( transformedBlocks ).toEqual( null ); + } ); + + it( 'should accept valid array transformations', () => { + registerBlockType( 'core/updated-text-block', defaultBlockSettings ); + registerBlockType( 'core/text-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/updated-text-block' ], + transform: ( { value } ) => { + return [ + createBlock( 'core/text-block', { + value: 'chicken ' + value, + } ), + createBlock( 'core/updated-text-block', { + value: 'smoked ' + value, + } ), + ]; + }, + } ], + }, + save: noop, + category: 'common', + title: 'text block', + } ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-text-block' ); + + // Make sure the block client IDs are set as expected: the first + // transformed block whose type matches the "destination" type gets + // to keep the existing block's client ID. + expect( transformedBlocks ).toHaveLength( 2 ); + expect( transformedBlocks[ 0 ] ).toHaveProperty( 'clientId' ); + expect( transformedBlocks[ 0 ].clientId ).not.toBe( block.clientId ); + expect( transformedBlocks[ 0 ].name ).toBe( 'core/text-block' ); + expect( transformedBlocks[ 0 ].isValid ).toBe( true ); + expect( transformedBlocks[ 0 ].attributes ).toEqual( { + value: 'chicken ribs', + } ); + expect( transformedBlocks[ 1 ].clientId ).toBe( block.clientId ); + expect( transformedBlocks[ 1 ] ).toHaveProperty( 'clientId' ); + expect( transformedBlocks[ 1 ].name ).toBe( 'core/updated-text-block' ); + expect( transformedBlocks[ 1 ].isValid ).toBe( true ); + expect( transformedBlocks[ 1 ].attributes ).toEqual( { + value: 'smoked ribs', + } ); + } ); + + it( 'should pass through inner blocks to transform', () => { + registerBlockType( 'core/updated-columns-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/columns-block' ], + transform( attributes, innerBlocks ) { + return createBlock( + 'core/updated-columns-block', + attributes, + innerBlocks.map( ( innerBlock ) => { + return cloneBlock( innerBlock, { + value: 'after', + } ); + } ), + ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'updated columns block', + } ); + registerBlockType( 'core/columns-block', defaultBlockSettings ); + registerBlockType( 'core/column-block', defaultBlockSettings ); + + const block = createBlock( + 'core/columns-block', + {}, + [ createBlock( 'core/column-block', { value: 'before' } ) ] + ); + + const transformedBlocks = switchToBlockType( block, 'core/updated-columns-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ].innerBlocks[ 0 ].attributes.value ).toBe( 'after' ); + } ); + + it( 'should pass through inner blocks to transform (multi)', () => { + registerBlockType( 'core/updated-columns-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ 'core/columns-block' ], + isMultiBlock: true, + transform( blocksAttributes, blocksInnerBlocks ) { + return blocksAttributes.map( ( attributes, i ) => { + return createBlock( + 'core/updated-columns-block', + attributes, + blocksInnerBlocks[ i ].map( ( innerBlock ) => { + return cloneBlock( innerBlock, { + value: 'after' + i, + } ); + } ), + ); + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'updated columns block', + } ); + registerBlockType( 'core/columns-block', defaultBlockSettings ); + registerBlockType( 'core/column-block', defaultBlockSettings ); + + const blocks = [ + createBlock( + 'core/columns-block', + {}, + [ createBlock( 'core/column-block', { value: 'before' } ) ] + ), + createBlock( + 'core/columns-block', + {}, + [ createBlock( 'core/column-block', { value: 'before' } ) ] + ), + ]; + + const transformedBlocks = switchToBlockType( blocks, 'core/updated-columns-block' ); + + expect( transformedBlocks ).toHaveLength( 2 ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ].innerBlocks[ 0 ].attributes.value ).toBe( 'after0' ); + expect( transformedBlocks[ 1 ].innerBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 1 ].innerBlocks[ 0 ].attributes.value ).toBe( 'after1' ); + } ); + + it( 'should pass entire block object(s) to the "__experimentalConvert" method if defined', () => { + registerBlockType( 'core/test-group-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + isMultiBlock: true, + __experimentalConvert( blocks ) { + const groupInnerBlocks = blocks.map( ( { name, attributes, innerBlocks } ) => { + return createBlock( name, attributes, innerBlocks ); + } ); + + return createBlock( 'core/test-group-block', {}, groupInnerBlocks ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'Test Group Block', + } ); + + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const numOfBlocksToGroup = 4; + const blocks = times( numOfBlocksToGroup, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const transformedBlocks = switchToBlockType( blocks, 'core/test-group-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ].name ).toBe( 'core/test-group-block' ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( numOfBlocksToGroup ); + } ); + + it( 'should prefer "__experimentalConvert" method over "transform" method when running a transformation', () => { + const convertSpy = jest.fn( ( blocks ) => { + const groupInnerBlocks = blocks.map( ( { name, attributes, innerBlocks } ) => { + return createBlock( name, attributes, innerBlocks ); + } ); + + return createBlock( 'core/test-group-block', {}, groupInnerBlocks ); + } ); + const transformSpy = jest.fn(); + + registerBlockType( 'core/test-group-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + isMultiBlock: true, + __experimentalConvert: convertSpy, + transform: transformSpy, + } ], + }, + save: noop, + category: 'common', + title: 'Test Group Block', + } ); + + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const numOfBlocksToGroup = 4; + const blocks = times( numOfBlocksToGroup, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const transformedBlocks = switchToBlockType( blocks, 'core/test-group-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( convertSpy.mock.calls ).toHaveLength( 1 ); + expect( transformSpy.mock.calls ).toHaveLength( 0 ); + } ); + } ); + + describe( 'getBlockTransforms', () => { + beforeEach( () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/transform-from-text-block-1', { + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + registerBlockType( 'core/transform-from-text-block-2', { + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + } ], + }, + save: noop, + category: 'common', + title: 'updated text block', + } ); + } ); + + it( 'should return all block types of direction', () => { + const transforms = getBlockTransforms( 'from' ); + + expect( transforms ).toEqual( [ + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-1', + }, + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-2', + }, + ] ); + } ); + + it( 'should return empty array if no block type by name', () => { + const transforms = getBlockTransforms( 'from', 'core/not-exists' ); + + expect( transforms ).toEqual( [] ); + } ); + + it( 'should return empty array if no defined transforms', () => { + const transforms = getBlockTransforms( 'to', 'core/transform-from-text-block-1' ); + + expect( transforms ).toEqual( [] ); + } ); + + it( 'should return single block type transforms of direction', () => { + const transforms = getBlockTransforms( 'from', 'core/transform-from-text-block-1' ); + + expect( transforms ).toEqual( [ + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-1', + }, + ] ); + } ); + + it( 'should return single block type transforms when passed as an object', () => { + const transforms = getBlockTransforms( + 'from', + getBlockType( 'core/transform-from-text-block-1' ) + ); + + expect( transforms ).toEqual( [ + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-1', + }, + ] ); + } ); + } ); + + describe( 'findTransform', () => { + const transforms = [ + { + blocks: [ 'core/text-block' ], + priority: 20, + blockName: 'core/transform-from-text-block-1', + }, + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-3', + priority: 5, + }, + { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-2', + }, + ]; + + it( 'should return highest priority (lowest numeric value) transform', () => { + const transform = findTransform( transforms, () => true ); + + expect( transform ).toEqual( { + blocks: [ 'core/text-block' ], + blockName: 'core/transform-from-text-block-3', + priority: 5, + } ); + } ); + + it( 'should return null if no matching transform', () => { + const transform = findTransform( transforms, () => false ); + + expect( transform ).toBe( null ); + } ); + } ); + + describe( 'isWildcardBlockTransform', () => { + it( 'should return true for transforms with type of block and "*" alias as blocks', () => { + const validWildcardBlockTransform = { + type: 'block', + blocks: [ + 'core/some-other-block-first', // unlikely to happen but... + '*', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( validWildcardBlockTransform ) ).toBe( true ); + } ); + + it( 'should return false for transforms with a type which is not "block"', () => { + const invalidWildcardBlockTransform = { + type: 'file', + blocks: [ + '*', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + + it( 'should return false for transforms which do not include "*" alias in "block" array', () => { + const invalidWildcardBlockTransform = { + type: 'block', + blocks: [ + 'core/some-block', + 'core/another-block', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + + it( 'should return false for transforms which do not provide an array as the "blocks" option', () => { + const invalidWildcardBlockTransform = { + type: 'block', + blocks: noop, + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + } ); + + describe( 'isContainerGroupBlock', () => { + beforeEach( () => { + registerBlockType( 'core/registered-grouping-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'A Block with InnerBlocks that supports grouping', + } ); + } ); + + it( 'should return true when passed block name that matches the registered "Grouping" Block', () => { + setGroupingBlockName( 'registered-grouping-block' ); + expect( isContainerGroupBlock( 'registered-grouping-block' ) ).toBe( true ); + } ); + + it( 'should return false when passed block name does not match the registered "Grouping" Block', () => { + setGroupingBlockName( 'registered-grouping-block' ); + expect( isContainerGroupBlock( 'core/group' ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockSelectionOfSameType', () => { + it( 'should return false when all blocks do not match the name of the first block', () => { + const blocks = [ + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/another-block', + }, + { + name: 'core/test-block', + }, + ]; + + expect( isBlockSelectionOfSameType( blocks ) ).toBe( false ); + } ); + + it( 'should return true when all blocks match the name of the first block', () => { + const blocks = [ + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + ]; + + expect( isBlockSelectionOfSameType( blocks ) ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/matchers.js b/gb-src/packages/blocks/src/api/test/matchers.js new file mode 100644 index 0000000000000..32d1246a2e9d6 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/matchers.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { parse } from 'hpq'; + +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as sources from '../matchers'; + +describe( 'matchers', () => { + describe( 'children()', () => { + it( 'should return a source function', () => { + const source = sources.children(); + + expect( typeof source ).toBe( 'function' ); + } ); + + it( 'should return HTML equivalent WPElement of matched element', () => { + // Assumption here is that we can cleanly convert back and forth + // between a string and WPElement representation + const html = '<blockquote><p>A delicious sundae dessert</p></blockquote>'; + const match = parse( html, sources.children() ); + + expect( renderToString( match ) ).toBe( html ); + } ); + } ); + + describe( 'node()', () => { + it( 'should return a source function', () => { + const source = sources.node(); + + expect( typeof source ).toBe( 'function' ); + } ); + + it( 'should return HTML equivalent WPElement of matched element', () => { + // Assumption here is that we can cleanly convert back and forth + // between a string and WPElement representation + const html = '<blockquote><p>A delicious sundae dessert</p></blockquote>'; + const match = parse( html, sources.node() ); + + expect( renderToString( match ) ).toBe( `<body>${ html }</body>` ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/node.js b/gb-src/packages/blocks/src/api/test/node.js new file mode 100644 index 0000000000000..447395a24ed12 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/node.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { + getNamedNodeMapAsObject, + toHTML, + fromDOM, +} from '../node'; + +describe( 'getNamedNodeMapAsObject', () => { + it( 'should return an object of node attributes', () => { + const node = document.createElement( 'img' ); + node.setAttribute( 'src', 'https://s.w.org/style/images/wporg-logo.svg' ); + + const object = getNamedNodeMapAsObject( node.attributes ); + expect( object ).toEqual( { + src: 'https://s.w.org/style/images/wporg-logo.svg', + } ); + } ); +} ); + +describe( 'toHTML', () => { + it( 'should convert a block node to its equivalent html string', () => { + const blockNode = { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'This is a test' ], + }, + }; + + const html = toHTML( blockNode ); + + expect( html ).toBe( '<strong class="is-extra-strong">This is a test</strong>' ); + } ); +} ); + +describe( 'fromDOM', () => { + it( 'should return a text node as its string node value', () => { + const node = document.createTextNode( 'Hello world' ); + + const blockNode = fromDOM( node ); + + expect( blockNode ).toBe( 'Hello world' ); + } ); + + it( 'should throw an error on receiving non-element/text node', () => { + expect( () => { + fromDOM( document.createDocumentFragment() ); + } ).toThrow( TypeError ); + } ); + + it( 'should return an equivalent block node, including children', () => { + const node = document.createElement( 'strong' ); + node.setAttribute( 'class', 'is-extra-strong' ); + node.innerHTML = 'Hello <em>world</em>!'; + + const blockNode = fromDOM( node ); + + expect( blockNode ).toEqual( { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ + 'Hello ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + '!', + ], + }, + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/parser.js b/gb-src/packages/blocks/src/api/test/parser.js new file mode 100644 index 0000000000000..026e71ca2cc5d --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/parser.js @@ -0,0 +1,1054 @@ +/** + * External dependencies + */ +import { attr } from 'hpq'; +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + getBlockAttribute, + getBlockAttributes, + createBlockWithFallback, + getMigratedBlock, + default as parsePegjs, + parseWithAttributeSchema, + toBooleanAttributeMatcher, + isOfType, + isOfTypes, + isValidByType, + isValidByEnum, + serializeBlockNode, +} from '../parser'; +import { + registerBlockType, + unregisterBlockType, + getBlockTypes, + setFreeformContentHandlerName, + setUnregisteredTypeHandlerName, +} from '../registration'; +import { createBlock } from '../factory'; +import serialize from '../serializer'; + +describe( 'block parser', () => { + const defaultBlockSettings = { + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit || null, + category: 'common', + title: 'block title', + }; + + const unknownBlockSettings = { + category: 'common', + title: 'unknown block', + attributes: { + content: { + type: 'string', + source: 'html', + }, + }, + save: ( { attributes } ) => attributes.content || null, + }; + + beforeAll( () => { + // Initialize the block store. + require( '../../store' ); + } ); + + afterEach( () => { + setFreeformContentHandlerName( undefined ); + setUnregisteredTypeHandlerName( undefined ); + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'toBooleanAttributeMatcher()', () => { + const originalMatcher = attr( 'disabled' ); + const enhancedMatcher = toBooleanAttributeMatcher( originalMatcher ); + + it( 'should return a matcher returning false on unset attribute', () => { + const node = document.createElement( 'input' ); + + expect( originalMatcher( node ) ).toBe( undefined ); + expect( enhancedMatcher( node ) ).toBe( false ); + } ); + + it( 'should return a matcher returning true on implicit empty string attribute value', () => { + const node = document.createElement( 'input' ); + node.disabled = true; + + expect( originalMatcher( node ) ).toBe( '' ); + expect( enhancedMatcher( node ) ).toBe( true ); + } ); + + it( 'should return a matcher returning true on explicit empty string attribute value', () => { + const node = document.createElement( 'input' ); + node.setAttribute( 'disabled', '' ); + + expect( originalMatcher( node ) ).toBe( '' ); + expect( enhancedMatcher( node ) ).toBe( true ); + } ); + + it( 'should return a matcher returning true on explicit string attribute value', () => { + const node = document.createElement( 'input' ); + node.setAttribute( 'disabled', 'disabled' ); + + expect( originalMatcher( node ) ).toBe( 'disabled' ); + expect( enhancedMatcher( node ) ).toBe( true ); + } ); + } ); + + describe( 'isOfType()', () => { + it( 'gracefully handles unhandled type', () => { + expect( isOfType( 5, '__UNHANDLED__' ) ).toBe( true ); + } ); + + it( 'returns expected result of type', () => { + expect( isOfType( '5', 'string' ) ).toBe( true ); + expect( isOfType( 5, 'string' ) ).toBe( false ); + + expect( isOfType( 5, 'integer' ) ).toBe( true ); + expect( isOfType( '5', 'integer' ) ).toBe( false ); + + expect( isOfType( 5, 'number' ) ).toBe( true ); + expect( isOfType( '5', 'number' ) ).toBe( false ); + + expect( isOfType( true, 'boolean' ) ).toBe( true ); + expect( isOfType( false, 'boolean' ) ).toBe( true ); + expect( isOfType( '5', 'boolean' ) ).toBe( false ); + expect( isOfType( 0, 'boolean' ) ).toBe( false ); + + expect( isOfType( null, 'null' ) ).toBe( true ); + expect( isOfType( 0, 'null' ) ).toBe( false ); + + expect( isOfType( [], 'array' ) ).toBe( true ); + + expect( isOfType( {}, 'object' ) ).toBe( true ); + expect( isOfType( null, 'object' ) ).toBe( false ); + } ); + } ); + + describe( 'isOfTypes', () => { + it( 'returns false if value is not one of types', () => { + expect( isOfTypes( null, [ 'string' ] ) ).toBe( false ); + } ); + + it( 'returns true if value is one of types', () => { + expect( isOfTypes( null, [ 'string', 'null' ] ) ).toBe( true ); + } ); + } ); + + describe( 'isValidByType', () => { + it( 'returns true if type undefined', () => { + expect( isValidByType( null ) ).toBe( true ); + } ); + + it( 'returns false if value is not one of types array', () => { + expect( isValidByType( null, [ 'string' ] ) ).toBe( false ); + } ); + + it( 'returns true if value is one of types array', () => { + expect( isValidByType( null, [ 'string', 'null' ] ) ).toBe( true ); + } ); + + it( 'returns false if value is not of type string', () => { + expect( isValidByType( null, 'string' ) ).toBe( false ); + } ); + + it( 'returns true if value is type string', () => { + expect( isValidByType( null, 'null' ) ).toBe( true ); + } ); + } ); + + describe( 'isValidByEnum', () => { + it( 'returns true if enum set undefined', () => { + expect( isValidByEnum( 2 ) ).toBe( true ); + } ); + + it( 'returns false if value is not of enum set', () => { + expect( isValidByEnum( 2, [ 1, 3 ] ) ).toBe( false ); + } ); + + it( 'returns true if value is of enum set', () => { + expect( isValidByEnum( 2, [ 1, 2, 3 ] ) ).toBe( true ); + } ); + } ); + + describe( 'parseWithAttributeSchema', () => { + it( 'should return the matcher’s attribute value', () => { + const value = parseWithAttributeSchema( + '<div>chicken</div>', + { + type: 'string', + source: 'text', + selector: 'div', + }, + ); + expect( value ).toBe( 'chicken' ); + } ); + + it( 'should return the matcher’s string attribute value', () => { + const value = parseWithAttributeSchema( + '<audio src="#" loop>', + { + type: 'string', + source: 'attribute', + selector: 'audio', + attribute: 'src', + }, + ); + expect( value ).toBe( '#' ); + } ); + + it( 'should return the matcher’s true boolean attribute value', () => { + const value = parseWithAttributeSchema( + '<audio src="#" loop>', + { + type: 'boolean', + source: 'attribute', + selector: 'audio', + attribute: 'loop', + }, + ); + expect( value ).toBe( true ); + } ); + + it( 'should return the matcher’s true boolean attribute value on explicit attribute value', () => { + const value = parseWithAttributeSchema( + '<audio src="#" loop="loop">', + { + type: 'boolean', + source: 'attribute', + selector: 'audio', + attribute: 'loop', + }, + ); + expect( value ).toBe( true ); + } ); + + it( 'should return the matcher’s false boolean attribute value', () => { + const value = parseWithAttributeSchema( + '<audio src="#" autoplay>', + { + type: 'boolean', + source: 'attribute', + selector: 'audio', + attribute: 'loop', + }, + ); + expect( value ).toBe( false ); + } ); + + describe( 'source: tag', () => { + it( 'returns tag name of matching selector', () => { + const value = parseWithAttributeSchema( + '<div></div>', + { + source: 'tag', + selector: ':nth-child(1)', + } + ); + + expect( value ).toBe( 'div' ); + } ); + + it( 'returns undefined when no element matches selector', () => { + const value = parseWithAttributeSchema( + '<div></div>', + { + source: 'tag', + selector: ':nth-child(2)', + } + ); + + expect( value ).toBe( undefined ); + } ); + } ); + } ); + + describe( 'getBlockAttribute', () => { + it( 'should return the comment attribute value', () => { + const value = getBlockAttribute( + 'number', + { + type: 'number', + }, + '', + { number: 10 } + ); + + expect( value ).toBe( 10 ); + } ); + + it( 'should reject type-invalid value, with default', () => { + const value = getBlockAttribute( + 'number', + { + type: 'string', + default: 5, + }, + '', + { number: 10 } + ); + + expect( value ).toBe( 5 ); + } ); + + it( 'should reject type-invalid value, without default', () => { + const value = getBlockAttribute( + 'number', + { + type: 'string', + }, + '', + { number: 10 } + ); + + expect( value ).toBe( undefined ); + } ); + + it( 'should reject enum-invalid value, with default', () => { + const value = getBlockAttribute( + 'number', + { + type: 'number', + enum: [ 4, 5, 6 ], + default: 5, + }, + '', + { number: 10 } + ); + + expect( value ).toBe( 5 ); + } ); + + it( 'should reject enum-invalid value, without default', () => { + const value = getBlockAttribute( + 'number', + { + type: 'number', + enum: [ 4, 5, 6 ], + }, + '', + { number: 10 } + ); + + expect( value ).toBe( undefined ); + } ); + + it( "should return the matcher's attribute value", () => { + const value = getBlockAttribute( + 'content', + { + type: 'string', + source: 'text', + selector: 'div', + }, + '<div>chicken</div>', + {} + ); + expect( value ).toBe( 'chicken' ); + } ); + + it( 'should return undefined for meta attributes', () => { + const value = getBlockAttribute( + 'content', + { + type: 'string', + source: 'meta', + meta: 'content', + }, + '<div>chicken</div>', + {} + ); + expect( value ).toBeUndefined(); + } ); + } ); + + describe( 'getBlockAttributes()', () => { + it( 'should reject the value with a wrong type', () => { + const blockType = { + attributes: { + number: { + type: 'number', + source: 'attribute', + attribute: 'data-number', + selector: 'div', + }, + }, + }; + + const innerHTML = '<div data-number="10">Ribs</div>'; + + expect( getBlockAttributes( blockType, innerHTML, {} ) ).toEqual( { + number: undefined, + } ); + } ); + + it( 'should merge attributes with the parsed and default attributes', () => { + const blockType = { + attributes: { + content: { + source: 'text', + selector: 'div', + }, + align: { + type: [ 'string', 'null' ], + }, + topic: { + type: 'string', + default: 'none', + }, + undefAmbiguousStringWithDefault: { + type: 'string', + source: 'attribute', + selector: 'div', + attribute: 'data-foo', + default: 'ok', + }, + }, + }; + + const innerHTML = '<div data-number="10">Ribs</div>'; + const attrs = { align: null, invalid: true }; + + expect( getBlockAttributes( blockType, innerHTML, attrs ) ).toEqual( { + content: 'Ribs', + align: null, + topic: 'none', + undefAmbiguousStringWithDefault: 'ok', + } ); + } ); + + it( 'should work when block type is passed as string', () => { + registerBlockType( 'core/meal', { + title: 'Meal', + category: 'widgets', + attributes: { + content: { + source: 'text', + selector: 'div', + }, + }, + save: () => {}, + } ); + + const innerHTML = '<div data-number="10">Ribs</div>'; + + expect( getBlockAttributes( 'core/meal', innerHTML ) ).toEqual( { + content: 'Ribs', + } ); + } ); + } ); + + describe( 'getMigratedBlock', () => { + it( 'should return the original block if it has no deprecated versions', () => { + const parsedAttributes = {}; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: '<span class="wp-block-test-block">Bananas</span>', + isValid: false, + } ); + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock ).toBe( block ); + } ); + + it( 'should return the original block if no valid deprecated version found', () => { + const parsedAttributes = {}; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: '<span class="wp-block-test-block">Bananas</span>', + isValid: false, + } ); + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + deprecated: [ + { + save() { + return 'nothing'; + }, + }, + ], + } ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock ).toEqual( expect.objectContaining( block ) ); + } ); + + it( 'should return with attributes parsed by the deprecated version', () => { + const parsedAttributes = {}; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: '<span>Bananas</span>', + isValid: false, + } ); + + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + save: ( props ) => <div>{ props.attributes.fruit }</div>, + deprecated: [ + { + attributes: { + fruit: { + type: 'string', + source: 'text', + selector: 'span', + }, + }, + save: ( props ) => <span>{ props.attributes.fruit }</span>, + }, + ], + } ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock.attributes ).toEqual( { fruit: 'Bananas' } ); + } ); + + it( 'should be able to migrate attributes and innerBlocks', () => { + const parsedAttributes = {}; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: '<span>Bananas</span>', + isValid: false, + } ); + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + save: ( props ) => <div>{ props.attributes.fruit }</div>, + deprecated: [ + { + attributes: { + fruit: { + type: 'string', + source: 'text', + selector: 'span', + }, + }, + save: ( props ) => <span>{ props.attributes.fruit }</span>, + migrate: ( attributes ) => { + return [ + { newFruit: attributes.fruit }, + [ { + name: 'core/test-block', + attributes: { aaa: 'bbb' }, + } ], + ]; + }, + }, + ], + } ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock.attributes ).toEqual( { newFruit: 'Bananas' } ); + expect( migratedBlock.innerBlocks ).toHaveLength( 1 ); + expect( migratedBlock.innerBlocks[ 0 ].name ).toEqual( 'core/test-block' ); + expect( migratedBlock.innerBlocks[ 0 ].attributes ).toEqual( { aaa: 'bbb' } ); + } ); + + it( 'should ignore valid uneligible blocks', () => { + const parsedAttributes = { fruit: 'Bananas' }; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: 'Bananas', + isValid: true, + } ); + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + deprecated: [ + { + attributes: defaultBlockSettings.attributes, + save: defaultBlockSettings.save, + migrate( attributes ) { + return { + fruit: attributes.fruit + '!', + }; + }, + }, + ], + } ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock.attributes ).toEqual( { fruit: 'Bananas' } ); + } ); + + it( 'should allow opt-in eligibility of valid block', () => { + const parsedAttributes = { fruit: 'Bananas' }; + const block = deepFreeze( { + name: 'core/test-block', + attributes: parsedAttributes, + originalContent: 'Bananas', + isValid: true, + } ); + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + deprecated: [ + { + attributes: defaultBlockSettings.attributes, + save: defaultBlockSettings.save, + isEligible: () => true, + migrate( attributes ) { + return { + fruit: attributes.fruit + '!', + }; + }, + }, + ], + } ); + + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + + expect( migratedBlock.attributes ).toEqual( { fruit: 'Bananas!' } ); + } ); + + it( 'allows a default attribute to be deprecated', () => { + // The block's default fruit attribute has been changed from 'Bananas' to 'Oranges'. + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + fruit: { + type: 'string', + default: 'Oranges', + }, + }, + deprecated: [ + { + attributes: { + fruit: { + type: 'string', + default: 'Bananas', + }, + }, + save: defaultBlockSettings.save, + }, + ], + } ); + + // Because the fruits attribute is not sourced, when the block content was parsed no value for the + // fruit attribute was found. + const parsedAttributes = {}; + + // When the block was created, it was given the new default value for the fruit attribute of 'Oranges'. + // This is because unchanged default values are not saved to the comment delimeter attributes. + // Validation failed because this block was saved when the old default was 'Bananas' as reflected by the originalContent. + const block = deepFreeze( { + name: 'core/test-block', + attributes: { fruit: 'Oranges' }, + originalContent: 'Bananas', + isValid: false, + } ); + + // The migrated block successfully falls back to the old value of 'Bananas', allowing the block to + // continue to be used. + const migratedBlock = getMigratedBlock( block, parsedAttributes ); + expect( migratedBlock.attributes ).toEqual( { fruit: 'Bananas' } ); + } ); + } ); + + describe( 'createBlockWithFallback', () => { + it( 'should create the requested block if it exists', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: 'Bananas', + attrs: { fruit: 'Bananas' }, + } ); + expect( block.name ).toEqual( 'core/test-block' ); + expect( block.attributes ).toEqual( { fruit: 'Bananas' } ); + } ); + + it( 'should create the requested block with no attributes if it exists', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: '', + } ); + expect( block.name ).toEqual( 'core/test-block' ); + expect( block.attributes ).toEqual( {} ); + } ); + + it( 'should fall back to the unregistered type handler for unregistered blocks if present', () => { + registerBlockType( 'core/unregistered-block', unknownBlockSettings ); + setUnregisteredTypeHandlerName( 'core/unregistered-block' ); + + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: 'Bananas', + attrs: { fruit: 'Bananas' }, + } ); + expect( block.name ).toBe( 'core/unregistered-block' ); + expect( block.attributes.content ).toContain( 'wp:test-block' ); + } ); + + it( 'should fall back to the freeform content handler if block type not specified', () => { + registerBlockType( 'core/freeform-block', unknownBlockSettings ); + setFreeformContentHandlerName( 'core/freeform-block' ); + + const block = createBlockWithFallback( { + innerHTML: 'content', + } ); + expect( block.name ).toEqual( 'core/freeform-block' ); + expect( block.attributes ).toEqual( { content: '<p>content</p>' } ); + } ); + + it( 'should not create a block if no unknown type handler', () => { + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: '', + } ); + expect( block ).toBeUndefined(); + } ); + + it( 'should fallback to an older version of the block if the current one is invalid', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + fruit: { + type: 'string', + source: 'text', + selector: 'div', + }, + }, + save: ( { attributes } ) => <div>{ attributes.fruit }</div>, + deprecated: [ + { + attributes: { + fruit: { + type: 'string', + source: 'text', + selector: 'span', + }, + }, + save: ( { attributes } ) => <span>{ attributes.fruit }</span>, + migrate: ( attributes ) => ( { fruit: 'Big ' + attributes.fruit } ), + }, + ], + } ); + + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: '<span>Bananas</span>', + attrs: { fruit: 'Bananas' }, + } ); + expect( block.name ).toEqual( 'core/test-block' ); + expect( block.attributes ).toEqual( { fruit: 'Big Bananas' } ); + expect( block.isValid ).toBe( true ); + expect( console ).toHaveInformed(); + } ); + } ); + + describe( 'serializeBlockNode', () => { + it( 'reserializes block nodes', () => { + const expected = `<!-- wp:columns --> + <div class="wp-block-columns has-2-columns"> + <!-- wp:column --> + <div class="wp-block-column"> + <!-- wp:paragraph --> + <p>A</p> + <!-- /wp:paragraph --> + </div> + <!-- /wp:column --> + <!-- wp:column --> + <div class="wp-block-column"> + <!-- wp:group --> + <div class="wp-block-group"><div class="wp-block-group__inner-container"> + <!-- wp:list --> + <ul><li>B</li><li>C</li></ul> + <!-- /wp:list --> + <!-- wp:paragraph --> + <p>D</p> + <!-- /wp:paragraph --> + </div></div> + <!-- /wp:group --> + </div> + <!-- /wp:column --> + </div> + <!-- /wp:columns -->`.replace( /\t/g, '' ); + const input = { + blockName: 'core/columns', + attrs: {}, + innerBlocks: [ + { + blockName: 'core/column', + attrs: {}, + innerBlocks: [ + { + blockName: 'core/paragraph', + attrs: {}, + innerBlocks: [], + innerHTML: '<p>A</p>', + innerContent: [ '<p>A</p>' ], + }, + ], + innerHTML: '<div class="wp-block-column"></div>', + innerContent: [ + '<div class="wp-block-column">', + null, + '</div>', + ], + }, + { + blockName: 'core/column', + attrs: {}, + innerBlocks: [ + { + blockName: 'core/group', + attrs: {}, + innerBlocks: [ + { + blockName: 'core/list', + attrs: {}, + innerBlocks: [], + innerHTML: '<ul><li>B</li><li>C</li></ul>', + innerContent: [ '<ul><li>B</li><li>C</li></ul>' ], + }, + { + blockName: 'core/paragraph', + attrs: {}, + innerBlocks: [], + innerHTML: '<p>D</p>', + innerContent: [ '<p>D</p>' ], + }, + ], + innerHTML: '<div class="wp-block-group"><div class="wp-block-group__inner-container"></div></div>', + innerContent: [ + '<div class="wp-block-group"><div class="wp-block-group__inner-container">', + null, + '', + null, + '</div></div>' ], + }, + ], + innerHTML: '<div class="wp-block-column"></div>', + innerContent: [ + '<div class="wp-block-column">', + null, + '</div>', + ], + }, + ], + innerHTML: '<div class="wp-block-columns has-2-columns"></div>', + innerContent: [ + '<div class="wp-block-columns has-2-columns">', + null, + '', + null, + '</div>', + ], + }; + const actual = serializeBlockNode( input ); + + expect( actual ).toEqual( expected ); + } ); + } ); + + describe( 'parse() of @wordpress/block-serialization-spec-parser', () => { + // run the test cases using the PegJS defined parser + testCases( parsePegjs ); + } ); + + // encapsulate the test cases so we can run them multiple time but with a different parse() function + function testCases( parse ) { + it( 'should parse the post content, including block attributes', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: { + type: 'string', + source: 'text', + }, + smoked: { type: 'string' }, + url: { type: 'string' }, + chicken: { type: 'string' }, + }, + save: ( { attributes } ) => attributes.content, + category: 'common', + title: 'test block', + } ); + + const parsed = parse( + `<!-- wp:core/test-block {"smoked":"yes","url":"http://google.com","chicken":"ribs & 'wings'"} -->` + + 'Brisket' + + '<!-- /wp:core/test-block -->' + ); + + expect( parsed ).toHaveLength( 1 ); + expect( parsed[ 0 ].name ).toBe( 'core/test-block' ); + expect( parsed[ 0 ].attributes ).toEqual( { + content: 'Brisket', + smoked: 'yes', + url: 'http://google.com', + chicken: "ribs & 'wings'", + } ); + expect( typeof parsed[ 0 ].clientId ).toBe( 'string' ); + } ); + + it( 'should parse empty post content', () => { + const parsed = parse( '' ); + + expect( parsed ).toEqual( [] ); + } ); + + it( 'should parse the post content, ignoring unknown blocks', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: { + type: 'string', + source: 'text', + }, + }, + save: ( { attributes } ) => attributes.content, + category: 'common', + title: 'test block', + } ); + + const parsed = parse( + '<!-- wp:core/test-block -->\nRibs\n<!-- /wp:core/test-block -->' + + '<p>Broccoli</p>' + + '<!-- wp:core/unknown-block -->Ribs<!-- /wp:core/unknown-block -->' + ); + + expect( parsed ).toHaveLength( 1 ); + expect( parsed[ 0 ].name ).toBe( 'core/test-block' ); + expect( parsed[ 0 ].attributes ).toEqual( { + content: 'Ribs', + } ); + expect( typeof parsed[ 0 ].clientId ).toBe( 'string' ); + } ); + + it( 'should add the core namespace to un-namespaced blocks', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const parsed = parse( + '<!-- wp:test-block {"fruit":"Bananas"} -->\nBananas\n<!-- /wp:test-block -->' + ); + + expect( parsed ).toHaveLength( 1 ); + expect( parsed[ 0 ].name ).toBe( 'core/test-block' ); + } ); + + it( 'should ignore blocks with a bad namespace', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + setFreeformContentHandlerName( 'core/unknown-block' ); + setUnregisteredTypeHandlerName( 'core/unknown-block' ); + + const parsed = parse( + '<!-- wp:test-block {"fruit":"Bananas"} -->\nBananas\n<!-- /wp:test-block -->' + + '<p>Broccoli</p>' + + '<!-- wp:core/unknown/block -->Ribs<!-- /wp:core/unknown/block -->' + ); + expect( parsed ).toHaveLength( 1 ); + expect( parsed[ 0 ].name ).toBe( 'core/test-block' ); + } ); + + it( 'should parse the post content, using unknown block handler', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + registerBlockType( 'core/unknown-block', unknownBlockSettings ); + + setFreeformContentHandlerName( 'core/unknown-block' ); + + const parsed = parse( + '<!-- wp:test-block {"fruit":"Bananas"} -->\nBananas\n<!-- /wp:test-block -->' + + '<p>Broccoli</p>' + + '<!-- wp:core/unknown-block -->Ribs<!-- /wp:core/unknown-block -->' + ); + + expect( parsed ).toHaveLength( 3 ); + expect( parsed.map( ( { name } ) => name ) ).toEqual( [ + 'core/test-block', + 'core/unknown-block', + 'core/unknown-block', + ] ); + } ); + + it( 'should parse the post content, including raw HTML at each end', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + registerBlockType( 'core/unknown-block', unknownBlockSettings ); + + setFreeformContentHandlerName( 'core/unknown-block' ); + + const parsed = parse( + '<p>Cauliflower</p>' + + '<!-- wp:test-block {"fruit":"Bananas"} -->\nBananas\n<!-- /wp:test-block -->' + + '\n<p>Broccoli</p>\n' + + '<!-- wp:test-block {"fruit":"Bananas"} -->\nBananas\n<!-- /wp:test-block -->' + + '<p>Romanesco</p>' + ); + + expect( parsed ).toHaveLength( 5 ); + expect( parsed.map( ( { name } ) => name ) ).toEqual( [ + 'core/unknown-block', + 'core/test-block', + 'core/unknown-block', + 'core/test-block', + 'core/unknown-block', + ] ); + expect( parsed[ 0 ].attributes.content ).toEqual( '<p>Cauliflower</p>' ); + expect( parsed[ 2 ].attributes.content ).toEqual( '<p>Broccoli</p>' ); + expect( parsed[ 4 ].attributes.content ).toEqual( '<p>Romanesco</p>' ); + } ); + + it( 'should parse blocks with empty content', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + const parsed = parse( + '<!-- wp:core/test-block --><!-- /wp:core/test-block -->' + ); + + expect( parsed ).toHaveLength( 1 ); + expect( parsed.map( ( { name } ) => name ) ).toEqual( [ + 'core/test-block', + ] ); + } ); + + it( 'should parse void blocks', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + registerBlockType( 'core/void-block', defaultBlockSettings ); + const parsed = parse( + '<!-- wp:core/test-block --><!-- /wp:core/test-block -->' + + '<!-- wp:core/void-block /-->' + ); + + expect( parsed ).toHaveLength( 2 ); + expect( parsed.map( ( { name } ) => name ) ).toEqual( [ + 'core/test-block', 'core/void-block', + ] ); + } ); + + it( 'should parse with unicode escaped returned to original representation', () => { + registerBlockType( 'core/code', { + category: 'common', + title: 'Code Block', + attributes: { + content: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.content, + } ); + + const content = '$foo = "My \"escaped\" text.";'; + const block = createBlock( 'core/code', { content } ); + const serialized = serialize( block ); + const parsed = parse( serialized ); + expect( parsed[ 0 ].attributes.content ).toBe( content ); + } ); + } +} ); diff --git a/gb-src/packages/blocks/src/api/test/registration.js b/gb-src/packages/blocks/src/api/test/registration.js new file mode 100644 index 0000000000000..b3e558edb0536 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/registration.js @@ -0,0 +1,716 @@ +/* eslint-disable react/forbid-elements */ + +/** + * External dependencies + */ +import { noop, get, omit, pick } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addFilter, removeAllFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { + registerBlockType, + unregisterBlockType, + setFreeformContentHandlerName, + getFreeformContentHandlerName, + setUnregisteredTypeHandlerName, + getUnregisteredTypeHandlerName, + setDefaultBlockName, + getDefaultBlockName, + getGroupingBlockName, + setGroupingBlockName, + getBlockType, + getBlockTypes, + getBlockSupport, + hasBlockSupport, + isReusableBlock, + serverSideBlockDefinitions, + unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase + DEFAULT_BLOCK_TYPE_SETTINGS, +} from '../registration'; +import { DEPRECATED_ENTRY_KEYS } from '../constants'; + +describe( 'blocks', () => { + const defaultBlockSettings = { save: noop, category: 'common', title: 'block title' }; + + beforeAll( () => { + // Initialize the block store. + require( '../../store' ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + setFreeformContentHandlerName( undefined ); + setUnregisteredTypeHandlerName( undefined ); + setDefaultBlockName( undefined ); + unstable__bootstrapServerSideBlockDefinitions( {} ); + } ); + + describe( 'registerBlockType()', () => { + it( 'should reject numbers', () => { + const block = registerBlockType( 999 ); + expect( console ).toHaveErroredWith( 'Block names must be strings.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks without a namespace', () => { + const block = registerBlockType( 'doing-it-wrong' ); + expect( console ).toHaveErroredWith( 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with too many namespaces', () => { + const block = registerBlockType( 'doing/it/wrong' ); + expect( console ).toHaveErroredWith( 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with invalid characters', () => { + const block = registerBlockType( 'still/_doing_it_wrong' ); + expect( console ).toHaveErroredWith( 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with uppercase characters', () => { + const block = registerBlockType( 'Core/Paragraph' ); + expect( console ).toHaveErroredWith( 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks not starting with a letter', () => { + const block = registerBlockType( 'my-plugin/4-fancy-block', defaultBlockSettings ); + expect( console ).toHaveErroredWith( 'Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-block' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should accept valid block names', () => { + const block = registerBlockType( 'my-plugin/fancy-block-4', defaultBlockSettings ); + expect( console ).not.toHaveErrored(); + expect( block ).toEqual( { + name: 'my-plugin/fancy-block-4', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + save: noop, + category: 'common', + title: 'block title', + } ); + } ); + + it( 'should prohibit registering the same block twice', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + const block = registerBlockType( 'core/test-block', defaultBlockSettings ); + expect( console ).toHaveErroredWith( 'Block "core/test-block" is already registered.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with invalid save function', () => { + const block = registerBlockType( 'my-plugin/fancy-block-5', { + ...defaultBlockSettings, + attributes: {}, + keywords: [], + save: 'invalid', + } ); + expect( console ).toHaveErroredWith( 'The "save" property must be a valid function.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with an invalid edit function', () => { + const blockType = { save: noop, edit: 'not-a-function', category: 'common', title: 'block title' }, + block = registerBlockType( 'my-plugin/fancy-block-6', blockType ); + expect( console ).toHaveErroredWith( 'The "edit" property must be a valid function.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks without category', () => { + const blockType = { settingName: 'settingValue', save: noop, title: 'block title' }, + block = registerBlockType( 'my-plugin/fancy-block-8', blockType ); + expect( console ).toHaveErroredWith( 'The block "my-plugin/fancy-block-8" must have a category.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with non registered category.', () => { + const blockType = { save: noop, category: 'custom-category-slug', title: 'block title' }, + block = registerBlockType( 'my-plugin/fancy-block-9', blockType ); + expect( console ).toHaveErroredWith( 'The block "my-plugin/fancy-block-9" must have a registered category.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks without title', () => { + const blockType = { settingName: 'settingValue', save: noop, category: 'common' }, + block = registerBlockType( 'my-plugin/fancy-block-9', blockType ); + expect( console ).toHaveErroredWith( 'The block "my-plugin/fancy-block-9" must have a title.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks with empty titles', () => { + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: '' }, + block = registerBlockType( 'my-plugin/fancy-block-10', blockType ); + expect( console ).toHaveErroredWith( 'The block "my-plugin/fancy-block-10" must have a title.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject titles which are not strings', () => { + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: 12345 }, + block = registerBlockType( 'my-plugin/fancy-block-11', blockType ); + expect( console ).toHaveErroredWith( 'Block titles must be strings.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should assign default settings', () => { + registerBlockType( 'core/test-block-with-defaults', { + title: 'block title', + category: 'common', + } ); + + expect( getBlockType( 'core/test-block-with-defaults' ) ).toEqual( { + name: 'core/test-block-with-defaults', + title: 'block title', + category: 'common', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + save: expect.any( Function ), + } ); + } ); + + it( 'should default to browser-initialized global attributes', () => { + const attributes = { ok: { type: 'boolean' } }; + unstable__bootstrapServerSideBlockDefinitions( { + 'core/test-block-with-attributes': { attributes }, + } ); + + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: 'block title' }; + registerBlockType( 'core/test-block-with-attributes', blockType ); + expect( getBlockType( 'core/test-block-with-attributes' ) ).toEqual( { + name: 'core/test-block-with-attributes', + settingName: 'settingValue', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: { + ok: { + type: 'boolean', + }, + }, + keywords: [], + } ); + } ); + + it( 'should validate the icon', () => { + const blockType = { + save: noop, + category: 'common', + title: 'block title', + icon: { chicken: 'ribs' }, + }; + const block = registerBlockType( 'core/test-block-icon-normalize-element', blockType ); + expect( console ).toHaveErrored(); + expect( block ).toBeUndefined(); + } ); + + it( 'should normalize the icon containing an element', () => { + const blockType = { + save: noop, + category: 'common', + title: 'block title', + icon: ( <svg width="20" height="20" viewBox="0 0 20 20"> + <circle cx="10" cy="10" r="10" + fill="red" stroke="blue" strokeWidth="10" /> + </svg> ), + }; + registerBlockType( 'core/test-block-icon-normalize-element', blockType ); + expect( getBlockType( 'core/test-block-icon-normalize-element' ) ).toEqual( { + name: 'core/test-block-icon-normalize-element', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: ( <svg width="20" height="20" viewBox="0 0 20 20"> + <circle cx="10" cy="10" r="10" + fill="red" stroke="blue" strokeWidth="10" /> + </svg> ), + }, + attributes: {}, + keywords: [], + } ); + } ); + + it( 'should normalize the icon containing a string', () => { + const blockType = { + save: noop, + category: 'common', + title: 'block title', + icon: 'foo', + }; + registerBlockType( 'core/test-block-icon-normalize-string', blockType ); + expect( getBlockType( 'core/test-block-icon-normalize-string' ) ).toEqual( { + name: 'core/test-block-icon-normalize-string', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'foo', + }, + attributes: {}, + keywords: [], + } ); + } ); + + it( 'should normalize the icon containing a function', () => { + const MyTestIcon = () => { + return <svg width="20" height="20" viewBox="0 0 20 20"> + <circle cx="10" cy="10" r="10" + fill="red" stroke="blue" strokeWidth="10" /> + </svg>; + }; + const blockType = { + save: noop, + category: 'common', + title: 'block title', + icon: MyTestIcon, + }; + registerBlockType( 'core/test-block-icon-normalize-function', blockType ); + expect( getBlockType( 'core/test-block-icon-normalize-function' ) ).toEqual( { + name: 'core/test-block-icon-normalize-function', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: MyTestIcon, + }, + attributes: {}, + keywords: [], + } ); + } ); + + it( 'should correctly register an icon with background and a custom svg', () => { + const blockType = { + save: noop, + category: 'common', + title: 'block title', + icon: { + background: '#f00', + src: ( <svg width="20" height="20" viewBox="0 0 20 20"> + <circle cx="10" cy="10" r="10" + fill="red" stroke="blue" strokeWidth="10" /> + </svg> ), + }, + }; + registerBlockType( 'core/test-block-icon-normalize-background', blockType ); + expect( getBlockType( 'core/test-block-icon-normalize-background' ) ).toEqual( { + name: 'core/test-block-icon-normalize-background', + save: noop, + category: 'common', + title: 'block title', + icon: { + background: '#f00', + foreground: '#191e23', + shadowColor: 'rgba(255, 0, 0, 0.3)', + src: ( <svg width="20" height="20" viewBox="0 0 20 20"> + <circle cx="10" cy="10" r="10" + fill="red" stroke="blue" strokeWidth="10" /> + </svg> ), + }, + attributes: {}, + keywords: [], + } ); + } ); + + it( 'should store a copy of block type', () => { + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: 'block title' }; + registerBlockType( 'core/test-block-with-settings', blockType ); + blockType.mutated = true; + expect( getBlockType( 'core/test-block-with-settings' ) ).toEqual( { + name: 'core/test-block-with-settings', + settingName: 'settingValue', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + } ); + } ); + + describe( 'applyFilters', () => { + afterEach( () => { + removeAllFilters( 'blocks.registerBlockType' ); + } ); + + it( 'should reject valid blocks when they become invalid after executing filter', () => { + addFilter( 'blocks.registerBlockType', 'core/blocks/without-title', ( settings ) => { + return { + ...settings, + title: '', + }; + } ); + const block = registerBlockType( 'my-plugin/fancy-block-12', defaultBlockSettings ); + expect( console ).toHaveErroredWith( 'The block "my-plugin/fancy-block-12" must have a title.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should reject blocks which become invalid after executing filter which does not return a plain object', () => { + addFilter( 'blocks.registerBlockType', 'core/blocks/without-save', ( settings ) => { + return [ settings ]; + } ); + const block = registerBlockType( 'my-plugin/fancy-block-13', defaultBlockSettings ); + expect( console ).toHaveErroredWith( 'Block settings must be a valid object.' ); + expect( block ).toBeUndefined(); + } ); + + it( 'should apply the blocks.registerBlockType filter to each of the deprecated settings as well as the main block settings', () => { + const name = 'my-plugin/fancy-block-13'; + const blockSettingsWithDeprecations = { + ...defaultBlockSettings, + deprecated: [ + { + save() { + return 1; + }, + }, + { + save() { + return 2; + }, + }, + ], + }; + + let i = 0; + addFilter( 'blocks.registerBlockType', 'core/blocks/without-title', ( settings ) => { + // Verify that for deprecations, the filter is called with a merge of pre-filter + // settings with deprecation keys omitted and the deprecation entry. + if ( i > 0 ) { + expect( settings ).toEqual( { + ...omit( + { + name, + ...DEFAULT_BLOCK_TYPE_SETTINGS, + ...get( serverSideBlockDefinitions, name ), + ...blockSettingsWithDeprecations, + }, + DEPRECATED_ENTRY_KEYS + ), + ...blockSettingsWithDeprecations.deprecated[ i - 1 ], + } ); + } + i++; + + return { + ...settings, + attributes: { + ...settings.attributes, + id: { + type: 'string', + }, + }, + }; + } ); + + const block = registerBlockType( name, blockSettingsWithDeprecations ); + + expect( block.attributes.id ).toEqual( { type: 'string' } ); + block.deprecated.forEach( ( deprecation ) => { + expect( deprecation.attributes.id ).toEqual( { type: 'string' } ); + // Verify that the deprecation's keys are a subset of deprecation keys. + expect( deprecation ).toEqual( pick( deprecation, DEPRECATED_ENTRY_KEYS ) ); + } ); + } ); + } ); + } ); + + describe( 'unregisterBlockType()', () => { + it( 'should fail if a block is not registered', () => { + const oldBlock = unregisterBlockType( 'core/test-block' ); + expect( console ).toHaveErroredWith( 'Block "core/test-block" is not registered.' ); + expect( oldBlock ).toBeUndefined(); + } ); + + it( 'should unregister existing blocks', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + expect( getBlockTypes() ).toEqual( [ + { + name: 'core/test-block', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + }, + ] ); + const oldBlock = unregisterBlockType( 'core/test-block' ); + expect( console ).not.toHaveErrored(); + expect( oldBlock ).toEqual( { + name: 'core/test-block', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + } ); + expect( getBlockTypes() ).toEqual( [] ); + } ); + } ); + + describe( 'setFreeformContentHandlerName()', () => { + it( 'assigns unknown type handler', () => { + setFreeformContentHandlerName( 'core/test-block' ); + + expect( getFreeformContentHandlerName() ).toBe( 'core/test-block' ); + } ); + } ); + + describe( 'getFreeformContentHandlerName()', () => { + it( 'defaults to undefined', () => { + expect( getFreeformContentHandlerName() ).toBeNull(); + } ); + } ); + + describe( 'setUnregisteredTypeHandlerName()', () => { + it( 'assigns unknown type handler', () => { + setUnregisteredTypeHandlerName( 'core/test-block' ); + + expect( getUnregisteredTypeHandlerName() ).toBe( 'core/test-block' ); + } ); + } ); + + describe( 'getUnregisteredTypeHandlerName()', () => { + it( 'defaults to undefined', () => { + expect( getUnregisteredTypeHandlerName() ).toBeNull(); + } ); + } ); + + describe( 'setDefaultBlockName()', () => { + it( 'assigns default block name', () => { + setDefaultBlockName( 'core/test-block' ); + + expect( getDefaultBlockName() ).toBe( 'core/test-block' ); + } ); + } ); + + describe( 'getDefaultBlockName()', () => { + it( 'defaults to undefined', () => { + expect( getDefaultBlockName() ).toBeNull(); + } ); + } ); + + describe( 'getGroupingBlockName()', () => { + it( 'defaults to undefined', () => { + expect( getGroupingBlockName() ).toBeNull(); + } ); + } ); + + describe( 'setGroupingBlockName()', () => { + it( 'assigns default block name', () => { + setGroupingBlockName( 'core/test-block' ); + + expect( getGroupingBlockName() ).toBe( 'core/test-block' ); + } ); + } ); + + describe( 'getBlockType()', () => { + it( 'should return { name, save } for blocks with minimum settings', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + expect( getBlockType( 'core/test-block' ) ).toEqual( { + name: 'core/test-block', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + } ); + } ); + + it( 'should return all block type elements', () => { + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: 'block title' }; + registerBlockType( 'core/test-block-with-settings', blockType ); + expect( getBlockType( 'core/test-block-with-settings' ) ).toEqual( { + name: 'core/test-block-with-settings', + settingName: 'settingValue', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + } ); + } ); + } ); + + describe( 'getBlockTypes()', () => { + it( 'should return an empty array at first', () => { + expect( getBlockTypes() ).toEqual( [] ); + } ); + + it( 'should return all registered blocks', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + const blockType = { settingName: 'settingValue', save: noop, category: 'common', title: 'block title' }; + registerBlockType( 'core/test-block-with-settings', blockType ); + expect( getBlockTypes() ).toEqual( [ + { + name: 'core/test-block', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + }, + { + name: 'core/test-block-with-settings', + settingName: 'settingValue', + save: noop, + category: 'common', + title: 'block title', + icon: { + src: 'block-default', + }, + attributes: {}, + keywords: [], + }, + ] ); + } ); + } ); + + describe( 'getBlockSupport', () => { + it( 'should return undefined if block has no supports', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( getBlockSupport( 'core/test-block', 'foo' ) ).toBe( undefined ); + } ); + + it( 'should return block supports value', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( getBlockSupport( 'core/test-block', 'bar' ) ).toBe( true ); + } ); + + it( 'should return custom default supports if block does not define support by name', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( getBlockSupport( 'core/test-block', 'foo', true ) ).toBe( true ); + } ); + } ); + + describe( 'hasBlockSupport', () => { + it( 'should return false if block has no supports', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( false ); + } ); + + it( 'should return false if block does not define support by name', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( false ); + } ); + + it( 'should return custom default supports if block does not define support by name', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo', true ) ).toBe( true ); + } ); + + it( 'should return true if block type supports', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + foo: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( true ); + } ); + + it( 'should return true if block author defines unsupported but truthy value', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + foo: 'hmmm', + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( true ); + } ); + + it( 'should handle block settings object as argument to test', () => { + const settings = { + ...defaultBlockSettings, + supports: { + foo: true, + }, + }; + + expect( hasBlockSupport( settings, 'foo' ) ).toBe( true ); + } ); + } ); + + describe( 'isReusableBlock', () => { + it( 'should return true for a reusable block', () => { + const block = { name: 'core/block' }; + expect( isReusableBlock( block ) ).toBe( true ); + } ); + + it( 'should return false for other blocks', () => { + const block = { name: 'core/paragraph' }; + expect( isReusableBlock( block ) ).toBe( false ); + } ); + } ); +} ); + +/* eslint-enable react/forbid-elements */ diff --git a/gb-src/packages/blocks/src/api/test/serializer.js b/gb-src/packages/blocks/src/api/test/serializer.js new file mode 100644 index 0000000000000..28281b42ea8c6 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/serializer.js @@ -0,0 +1,388 @@ +/** + * WordPress dependencies + */ +import { createElement, Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import serialize, { + getCommentAttributes, + getSaveContent, + serializeAttributes, + getCommentDelimitedContent, + serializeBlock, + getBlockContent, +} from '../serializer'; +import { + getBlockTypes, + registerBlockType, + unregisterBlockType, + setFreeformContentHandlerName, + setUnregisteredTypeHandlerName, +} from '../registration'; +import { createBlock } from '../'; + +describe( 'block serializer', () => { + beforeAll( () => { + // Initialize the block store. + require( '../../store' ); + } ); + + afterEach( () => { + setFreeformContentHandlerName( undefined ); + setUnregisteredTypeHandlerName( undefined ); + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'getSaveContent()', () => { + describe( 'function save', () => { + const fruitBlockSave = ( { attributes } ) => createElement( 'div', null, attributes.fruit ); + + it( 'should return element as string if save returns element', () => { + const saved = getSaveContent( + { + name: 'core/fruit', + save: fruitBlockSave, + }, + { fruit: 'Bananas' } + ); + + expect( saved ).toBe( '<div>Bananas</div>' ); + } ); + + it( 'should work when block type is passed as string', () => { + registerBlockType( 'core/fruit', { + title: 'Fruit', + category: 'widgets', + save: fruitBlockSave, + } ); + + const saved = getSaveContent( + 'core/fruit', + { fruit: 'Bananas' } + ); + + expect( saved ).toBe( '<div>Bananas</div>' ); + } ); + } ); + + describe( 'component save', () => { + it( 'should return element as string', () => { + const saved = getSaveContent( + { + save: class extends Component { + render() { + return createElement( 'div', null, this.props.attributes.fruit ); + } + }, + name: 'core/fruit', + }, + { fruit: 'Bananas' } + ); + + expect( saved ).toBe( '<div>Bananas</div>' ); + } ); + } ); + } ); + + describe( 'getCommentAttributes()', () => { + it( 'should return an empty set if no attributes provided', () => { + const attributes = getCommentAttributes( {}, {} ); + + expect( attributes ).toEqual( {} ); + } ); + + it( 'should only return attributes which are not matched from content', () => { + const attributes = getCommentAttributes( + { + attributes: { + fruit: { + type: 'string', + source: 'text', + }, + category: { + type: 'string', + }, + ripeness: { + type: 'string', + }, + }, + }, + { + fruit: 'bananas', + category: 'food', + ripeness: 'ripe', + } + ); + + expect( attributes ).toEqual( { + category: 'food', + ripeness: 'ripe', + } ); + } ); + + it( 'should skip attributes whose values are undefined', () => { + const attributes = getCommentAttributes( + { + attributes: { + fruit: { + type: 'string', + }, + ripeness: { + type: 'string', + }, + }, + }, + { + fruit: 'bananas', + ripeness: undefined, + } + ); + + expect( attributes ).toEqual( { fruit: 'bananas' } ); + } ); + } ); + + describe( 'serializeAttributes()', () => { + it( 'should not break HTML comments', () => { + expect( serializeAttributes( { a: '-- and --' } ) ).toBe( '{"a":"\\u002d\\u002d and \\u002d\\u002d"}' ); + } ); + + it( 'should not break standard-non-compliant tools for "<"', () => { + expect( serializeAttributes( { a: '< and <' } ) ).toBe( '{"a":"\\u003c and \\u003c"}' ); + } ); + + it( 'should not break standard-non-compliant tools for ">"', () => { + expect( serializeAttributes( { a: '> and >' } ) ).toBe( '{"a":"\\u003e and \\u003e"}' ); + } ); + + it( 'should not break standard-non-compliant tools for "&"', () => { + expect( serializeAttributes( { a: '& and &' } ) ).toBe( '{"a":"\\u0026 and \\u0026"}' ); + } ); + + it( 'should replace quotation marks', () => { + expect( serializeAttributes( { a: '" and "' } ) ).toBe( '{"a":"\\u0022 and \\u0022"}' ); + } ); + } ); + + describe( 'getCommentDelimitedContent()', () => { + it( 'should generate empty attributes void', () => { + const content = getCommentDelimitedContent( + 'core/test-block', + {}, + '' + ); + + expect( content ).toBe( '<!-- wp:test-block /-->' ); + } ); + + it( 'should include the namespace for non-core blocks', () => { + const content = getCommentDelimitedContent( + 'my-wonderful-namespace/test-block', + {}, + '' + ); + + expect( content ).toBe( '<!-- wp:my-wonderful-namespace/test-block /-->' ); + } ); + + it( 'should generate empty attributes non-void', () => { + const content = getCommentDelimitedContent( + 'core/test-block', + {}, + 'Delicious' + ); + + expect( content ).toBe( '<!-- wp:test-block -->\nDelicious\n<!-- /wp:test-block -->' ); + } ); + + it( 'should generate non-empty attributes void', () => { + const content = getCommentDelimitedContent( + 'core/test-block', + { fruit: 'Banana' }, + '' + ); + + expect( content ).toBe( + '<!-- wp:test-block {"fruit":"Banana"} /-->' + ); + } ); + + it( 'should generate non-empty attributes non-void', () => { + const content = getCommentDelimitedContent( + 'core/test-block', + { fruit: 'Banana' }, + 'Delicious' + ); + + expect( content ).toBe( + '<!-- wp:test-block {"fruit":"Banana"} -->\nDelicious\n<!-- /wp:test-block -->' + ); + } ); + } ); + + describe( 'serializeBlock()', () => { + it( 'serializes the freeform content fallback block without comment delimiters', () => { + registerBlockType( 'core/freeform-block', { + category: 'common', + title: 'freeform block', + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit, + } ); + setFreeformContentHandlerName( 'core/freeform-block' ); + const block = createBlock( 'core/freeform-block', { fruit: 'Bananas' } ); + + const content = serializeBlock( block ); + + expect( content ).toBe( 'Bananas' ); + } ); + it( 'serializes the freeform content fallback block with comment delimiters in nested context', () => { + registerBlockType( 'core/freeform-block', { + category: 'common', + title: 'freeform block', + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit, + } ); + setFreeformContentHandlerName( 'core/freeform-block' ); + const block = createBlock( 'core/freeform-block', { fruit: 'Bananas' } ); + + const content = serializeBlock( block, { isInnerBlocks: true } ); + + expect( content ).toBe( + '<!-- wp:freeform-block {\"fruit\":\"Bananas\"} -->\n' + + 'Bananas\n' + + '<!-- /wp:freeform-block -->' + ); + } ); + it( 'serializes the unregistered fallback block without comment delimiters', () => { + registerBlockType( 'core/unregistered-block', { + category: 'common', + title: 'unregistered block', + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit, + } ); + setUnregisteredTypeHandlerName( 'core/unregistered-block' ); + const block = createBlock( 'core/unregistered-block', { fruit: 'Bananas' } ); + + const content = serializeBlock( block ); + + expect( content ).toBe( 'Bananas' ); + } ); + } ); + + describe( 'serialize()', () => { + beforeEach( () => { + const blockType = { + attributes: { + throw: { + type: 'boolean', + }, + defaulted: { + type: 'boolean', + default: false, + }, + content: { + type: 'string', + source: 'text', + }, + stuff: { + type: 'string', + }, + }, + save( { attributes } ) { + if ( attributes.throw ) { + throw new Error(); + } + + return ( + <p> + { attributes.content } + </p> + ); + }, + category: 'common', + title: 'block title', + }; + registerBlockType( 'core/test-block', blockType ); + } ); + + it( 'should serialize the post content properly', () => { + const block = createBlock( 'core/test-block', { + content: 'Ribs & Chicken', + stuff: 'left & right -- but <not>', + } ); + const expectedPostContent = '<!-- wp:test-block {"stuff":"left \\u0026 right \\u002d\\u002d but \\u003cnot\\u003e"} -->\n<p>Ribs & Chicken</p>\n<!-- /wp:test-block -->'; + + expect( serialize( [ block ] ) ).toEqual( expectedPostContent ); + expect( serialize( block ) ).toEqual( expectedPostContent ); + } ); + + it( 'should preserve content for invalid block', () => { + const block = createBlock( 'core/test-block', { + content: 'Incorrect', + } ); + + block.isValid = false; + block.originalContent = 'Correct'; + + expect( serialize( block ) ).toEqual( + '<!-- wp:test-block -->\nCorrect\n<!-- /wp:test-block -->' + ); + } ); + + it( 'should preserve content for crashing block', () => { + const block = createBlock( 'core/test-block', { + content: 'Incorrect', + throw: true, + } ); + + block.originalContent = 'Correct'; + + expect( serialize( block ) ).toEqual( + '<!-- wp:test-block {"throw":true} -->\nCorrect\n<!-- /wp:test-block -->' + ); + } ); + } ); + + describe( 'getBlockContent', () => { + it( "should return the block's serialized inner HTML", () => { + const blockType = { + attributes: { + content: { + type: 'string', + source: 'text', + }, + }, + save( { attributes } ) { + return attributes.content; + }, + category: 'common', + title: 'block title', + }; + registerBlockType( 'core/chicken', blockType ); + const block = { + name: 'core/chicken', + attributes: { + content: 'chicken', + }, + isValid: true, + }; + expect( getBlockContent( block ) ).toBe( 'chicken' ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/templates.js b/gb-src/packages/blocks/src/api/test/templates.js new file mode 100644 index 0000000000000..12730f75197eb --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/templates.js @@ -0,0 +1,190 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock } from '../factory'; +import { getBlockTypes, unregisterBlockType, registerBlockType } from '../registration'; +import { doBlocksMatchTemplate, synchronizeBlocksWithTemplate } from '../templates'; + +describe( 'templates', () => { + beforeAll( () => { + // Initialize the block store + require( '../../store' ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + beforeEach( () => { + registerBlockType( 'core/test-block', { + attributes: {}, + save: noop, + category: 'common', + title: 'test block', + } ); + + registerBlockType( 'core/test-block-2', { + attributes: {}, + save: noop, + category: 'common', + title: 'test block', + } ); + } ); + + describe( 'doBlocksMatchTemplate', () => { + it( 'return true if for empty templates and blocks', () => { + expect( doBlocksMatchTemplate() ).toBe( true ); + } ); + + it( 'return true if the template matches the blocks', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2' ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( true ); + } ); + + it( 'return true if the template matches the blocks with nested blocks', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2', {}, [ + [ 'core/test-block' ], + ] ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2', {}, [ createBlock( 'core/test-block' ) ] ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( true ); + } ); + + it( "return false if the template length doesn't match the blocks length", () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2' ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( false ); + } ); + + it( "return false if the nested template doesn't match the blocks", () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2', {}, [ + [ 'core/test-block' ], + ] ], + [ 'core/test-block-2' ], + ]; + const blockList = [ + createBlock( 'core/test-block' ), + createBlock( 'core/test-block-2', {}, [ createBlock( 'core/test-block-2' ) ] ), + createBlock( 'core/test-block-2' ), + ]; + expect( doBlocksMatchTemplate( blockList, template ) ).toBe( false ); + } ); + } ); + + describe( 'synchronizeBlocksWithTemplate', () => { + it( 'should create blocks for each template entry', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = []; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + { name: 'core/test-block' }, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should create nested blocks', () => { + const template = [ + [ 'core/test-block', {}, [ + [ 'core/test-block-2' ], + ] ], + ]; + const blockList = []; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + { name: 'core/test-block', innerBlocks: [ + { name: 'core/test-block-2' }, + ] }, + ] ); + } ); + + it( 'should append blocks if more blocks in the template', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block-2' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + block1, + block2, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should replace blocks if not matching blocks are found', () => { + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toMatchObject( [ + block1, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'should return original blocks if no template provided', () => { + let template; + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toBe( blockList ); + } ); + + it( 'should remove blocks if extra blocks are found', () => { + const template = [ + [ 'core/test-block' ], + ]; + + const block1 = createBlock( 'core/test-block' ); + const block2 = createBlock( 'core/test-block' ); + const blockList = [ block1, block2 ]; + expect( synchronizeBlocksWithTemplate( blockList, template ) ).toEqual( [ + block1, + ] ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/utils.js b/gb-src/packages/blocks/src/api/test/utils.js new file mode 100644 index 0000000000000..25f57337a8636 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/utils.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { createBlock } from '../factory'; +import { getBlockTypes, unregisterBlockType, registerBlockType, setDefaultBlockName } from '../registration'; +import { isUnmodifiedDefaultBlock } from '../utils'; + +describe( 'block helpers', () => { + beforeAll( () => { + // Initialize the block store + require( '../../store' ); + } ); + + afterEach( () => { + setDefaultBlockName( undefined ); + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'isUnmodifiedDefaultBlock()', () => { + it( 'should return true if the default block is unmodified', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + includesDefault: { + type: 'boolean', + default: true, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + setDefaultBlockName( 'core/test-block' ); + const unmodifiedBlock = createBlock( 'core/test-block' ); + expect( isUnmodifiedDefaultBlock( unmodifiedBlock ) ).toBe( true ); + } ); + + it( 'should return false if the default block is updated', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + includesDefault: { + type: 'boolean', + default: true, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + setDefaultBlockName( 'core/test-block' ); + const block = createBlock( 'core/test-block' ); + block.attributes.align = 'left'; + + expect( isUnmodifiedDefaultBlock( block ) ).toBe( false ); + } ); + + it( 'should invalidate cache if the default block name changed', () => { + registerBlockType( 'core/test-block1', { + attributes: { + includesDefault1: { + type: 'boolean', + default: true, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block2', { + attributes: { + includesDefault2: { + type: 'boolean', + default: true, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + setDefaultBlockName( 'core/test-block1' ); + isUnmodifiedDefaultBlock( createBlock( 'core/test-block1' ) ); + setDefaultBlockName( 'core/test-block2' ); + expect( isUnmodifiedDefaultBlock( createBlock( 'core/test-block2' ) ) ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/test/validation.js b/gb-src/packages/blocks/src/api/test/validation.js new file mode 100644 index 0000000000000..fcfb61bff6ae2 --- /dev/null +++ b/gb-src/packages/blocks/src/api/test/validation.js @@ -0,0 +1,665 @@ +/** + * Internal dependencies + */ +import { + isValidCharacterReference, + DecodeEntityParser, + getTextPiecesSplitOnWhitespace, + getTextWithCollapsedWhitespace, + getMeaningfulAttributePairs, + isEquivalentTextTokens, + getNormalizedStyleValue, + getStyleProperties, + isEqualAttributesOfName, + isEqualTagAttributePairs, + isEqualTokensOfType, + getNextNonWhitespaceToken, + isEquivalentHTML, + isValidBlockContent, + isClosedByToken, +} from '../validation'; +import { + registerBlockType, + unregisterBlockType, + getBlockTypes, + getBlockType, +} from '../registration'; + +describe( 'validation', () => { + const defaultBlockSettings = { + save: ( { attributes } ) => attributes.fruit, + category: 'common', + title: 'block title', + }; + beforeAll( () => { + // Initialize the block store + require( '../../store' ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + describe( 'isValidCharacterReference', () => { + it( 'returns true for a named character reference', () => { + const result = isValidCharacterReference( 'blk12' ); + + expect( result ).toBe( true ); + } ); + + it( 'returns true for a decimal character reference', () => { + const result = isValidCharacterReference( '#33' ); + + expect( result ).toBe( true ); + } ); + + it( 'returns true for a hexadecimal character reference', () => { + const result = isValidCharacterReference( '#xC6' ); + + expect( result ).toBe( true ); + } ); + + it( 'returns false for an invalid character reference', () => { + const result = isValidCharacterReference( ' Test</h2><h2>Test &' ); + + expect( result ).toBe( false ); + } ); + } ); + + describe( 'DecodeEntityParser', () => { + it( 'can be constructed', () => { + expect( new DecodeEntityParser() instanceof DecodeEntityParser ).toBe( true ); + } ); + + it( 'returns parse as decoded value', () => { + expect( new DecodeEntityParser().parse( 'quot' ) ).toBe( '"' ); + } ); + } ); + + describe( 'getTextPiecesSplitOnWhitespace()', () => { + it( 'returns text pieces spilt on whitespace', () => { + const pieces = getTextPiecesSplitOnWhitespace( ' a \t b \n c' ); + + expect( pieces ).toEqual( [ 'a', 'b', 'c' ] ); + } ); + } ); + + describe( 'getTextWithCollapsedWhitespace()', () => { + it( 'returns text with collapsed whitespace', () => { + const pieces = getTextWithCollapsedWhitespace( ' a \t b \n c' ); + + expect( pieces ).toBe( 'a b c' ); + } ); + } ); + + describe( 'getMeaningfulAttributePairs()', () => { + it( 'returns with non-empty attributes', () => { + const pairs = getMeaningfulAttributePairs( { + attributes: [ [ 'class', 'a' ] ], + } ); + + expect( pairs ).toEqual( [ [ 'class', 'a' ] ] ); + } ); + + it( 'returns without empty non-boolean, non-enumerated attributes', () => { + const pairs = getMeaningfulAttributePairs( { + attributes: [ [ 'class', '' ] ], + } ); + + expect( pairs ).toEqual( [] ); + } ); + + it( 'returns with empty boolean attributes', () => { + const pairs = getMeaningfulAttributePairs( { + attributes: [ [ 'disabled', '' ] ], + } ); + + expect( pairs ).toEqual( [ [ 'disabled', '' ] ] ); + } ); + + it( 'returns with empty enumerated attributes', () => { + const pairs = getMeaningfulAttributePairs( { + attributes: [ [ 'contenteditable', '' ] ], + } ); + + expect( pairs ).toEqual( [ [ 'contenteditable', '' ] ] ); + } ); + + it( 'returns with empty data- attributes', () => { + const pairs = getMeaningfulAttributePairs( { + attributes: [ [ 'data-foo', '' ] ], + } ); + + expect( pairs ).toEqual( [ [ 'data-foo', '' ] ] ); + } ); + } ); + + describe( 'isEquivalentTextTokens()', () => { + it( 'should return false if not equal with collapsed whitespace', () => { + const isEqual = isEquivalentTextTokens( + { chars: ' a \t b \n c' }, + { chars: 'a \n c \t b ' }, + ); + + expect( console ).toHaveWarned(); + expect( isEqual ).toBe( false ); + } ); + + it( 'should return true if equal with collapsed whitespace', () => { + const isEqual = isEquivalentTextTokens( + { chars: ' a \t b \n c' }, + { chars: 'a \n b \t c ' }, + ); + + expect( isEqual ).toBe( true ); + } ); + } ); + + describe( 'getNormalizedStyleValue()', () => { + it( 'omits whitespace and quotes from url value', () => { + const normalizedValue = getNormalizedStyleValue( + 'url( "https://wordpress.org/img.png" )' + ); + + expect( normalizedValue ).toBe( 'url(https://wordpress.org/img.png)' ); + } ); + } ); + + describe( 'getStyleProperties()', () => { + it( 'returns style property pairs', () => { + const pairs = getStyleProperties( + 'background-image: url( "https://wordpress.org/img.png" ); color: red;' + ); + + expect( pairs ).toEqual( { + 'background-image': 'url(https://wordpress.org/img.png)', + color: 'red', + } ); + } ); + } ); + + describe( 'isEqualAttributesOfName', () => { + describe( '.class()', () => { + it( 'ignores ordering', () => { + const isEqual = isEqualAttributesOfName.class( + 'a b c', + 'b a c', + ); + + expect( isEqual ).toBe( true ); + } ); + + it( 'ignores whitespace', () => { + const isEqual = isEqualAttributesOfName.class( + 'a b c', + 'b a c', + ); + + expect( isEqual ).toBe( true ); + } ); + + it( 'returns false if not equal', () => { + const isEqual = isEqualAttributesOfName.class( + 'a b c', + 'b a c d', + ); + + expect( isEqual ).toBe( false ); + } ); + } ); + + describe( '.style()', () => { + it( 'returns true if the same style', () => { + const isEqual = isEqualAttributesOfName.style( + 'background-image: url( "https://wordpress.org/img.png" ); color: red;', + "color: red; background-image: url('https://wordpress.org/img.png\n);" + ); + + expect( isEqual ).toBe( true ); + } ); + + it( 'returns false if not same style', () => { + const isEqual = isEqualAttributesOfName.style( + 'background-image: url( "https://wordpress.org/img.png" ); color: red;', + "color: red; font-size: 13px; background-image: url('https://wordpress.org/img.png');" + ); + + expect( isEqual ).toBe( false ); + } ); + } ); + + describe( 'boolean attributes', () => { + it( 'returns true if both present', () => { + const isEqual = isEqualAttributesOfName.controls( + 'true', + '' + ); + + expect( isEqual ).toBe( true ); + } ); + } ); + } ); + + describe( 'isEqualTagAttributePairs()', () => { + it( 'returns false if not equal pairs', () => { + const isEqual = isEqualTagAttributePairs( + [ + [ 'class', 'b a c' ], + ], + [ + [ 'class', 'c a b' ], + [ 'style', 'background-image: url( "https://wordpress.org/img.png" ); color: red;' ], + ] + ); + + expect( console ).toHaveWarned(); + expect( isEqual ).toBe( false ); + } ); + + it( 'returns true if equal pairs', () => { + const isEqual = isEqualTagAttributePairs( + [ + [ 'class', 'b a c' ], + [ 'style', 'color: red; background-image: url( "https://wordpress.org/img.png" );' ], + [ 'controls', '' ], + ], + [ + [ 'class', 'c a b' ], + [ 'style', 'background-image: url( "https://wordpress.org/img.png" ); color: red;' ], + [ 'controls', 'true' ], + ] + ); + + expect( isEqual ).toBe( true ); + } ); + } ); + + describe( 'isEqualTokensOfType', () => { + describe( '.StartTag()', () => { + it( 'returns false if tag name not the same', () => { + const isEqual = isEqualTokensOfType.StartTag( + { tagName: 'p' }, + { tagName: 'section' } + ); + + expect( console ).toHaveWarned(); + expect( isEqual ).toBe( false ); + } ); + + it( 'returns false if tag name the same but attributes not', () => { + const isEqual = isEqualTokensOfType.StartTag( + { + tagName: 'p', + attributes: [ + [ 'class', 'b a c' ], + ], + }, + { + tagName: 'p', + attributes: [ + [ 'class', 'c a b' ], + [ 'style', 'background-image: url( "https://wordpress.org/img.png" ); color: red;' ], + ], + } + ); + + expect( console ).toHaveWarned(); + expect( isEqual ).toBe( false ); + } ); + + it( 'returns true if tag name the same and attributes the same', () => { + const isEqual = isEqualTokensOfType.StartTag( + { + tagName: 'p', + attributes: [ + [ 'class', 'b a c' ], + [ 'style', 'color: red; background-image: url( "https://wordpress.org/img.png" );' ], + ], + }, + { + tagName: 'p', + attributes: [ + [ 'class', 'c a b' ], + [ 'style', 'background-image: url( "https://wordpress.org/img.png" ); color: red;' ], + ], + } + ); + + expect( isEqual ).toBe( true ); + } ); + } ); + } ); + + describe( 'getNextNonWhitespaceToken()', () => { + it( 'returns the next non-whitespace token, mutating array', () => { + const tokens = [ + { type: 'Chars', chars: ' \n\t' }, + { type: 'StartTag', tagName: 'p' }, + ]; + + const token = getNextNonWhitespaceToken( tokens ); + + expect( token ).toEqual( { type: 'StartTag', tagName: 'p' } ); + expect( tokens ).toHaveLength( 0 ); + } ); + + it( 'returns undefined if token options exhausted', () => { + const tokens = [ + { type: 'Chars', chars: ' \n\t' }, + ]; + + const token = getNextNonWhitespaceToken( tokens ); + + expect( token ).toBeUndefined(); + expect( tokens ).toHaveLength( 0 ); + } ); + } ); + + describe( 'isClosedByToken()', () => { + it( 'should return true if self-closed token is closed by an end token', () => { + const isClosed = isClosedByToken( + { type: 'StartTag', tagName: 'div', selfClosing: true }, + { type: 'EndTag', tagName: 'div' }, + ); + + expect( isClosed ).toBe( true ); + } ); + + it( 'should return false if open token is not closed by an end token', () => { + const isClosed = isClosedByToken( + { type: 'StartTag', tagName: 'div', selfClosing: false }, + { type: 'EndTag', tagName: 'div' }, + ); + + expect( isClosed ).toBe( false ); + } ); + + it( 'should return false if self-closed token has a different name to the end token', () => { + const isClosed = isClosedByToken( + { type: 'StartTag', tagName: 'div', selfClosing: true }, + { type: 'EndTag', tagName: 'span' }, + ); + + expect( isClosed ).toBe( false ); + } ); + + it( 'should return false if self-closed token is not closed by a start token', () => { + const isClosed = isClosedByToken( + { type: 'StartTag', tagName: 'div', selfClosing: true }, + { type: 'StartTag', tagName: 'div' }, + ); + + expect( isClosed ).toBe( false ); + } ); + + it( 'should return false if self-closed token is not closed by an undefined token', () => { + const isClosed = isClosedByToken( + { type: 'StartTag', tagName: 'div', selfClosing: true }, + undefined, + ); + + expect( isClosed ).toBe( false ); + } ); + } ); + + describe( 'isEquivalentHTML()', () => { + it( 'should return false for effectively inequivalent html', () => { + const isEquivalent = isEquivalentHTML( + '<div>Hello <span class="b">World!</span></div>', + '<div>Hello <span class="a">World!</span></div>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return true for effectively equivalent html', () => { + const isEquivalent = isEquivalentHTML( + '<div>" Hello<span class="b a" id="foo" data-foo="here — there"> World! 😅</ span> "</div>', + '<div >" Hello\n<span id="foo" class="a b" data-foo="here — there">World! 😅</span>"</div>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should account for character reference validity', () => { + // Regression: Previously the validator would wrongly evaluate the + // segment of text ` Test</h2><h2>Test &` as a character + // reference, as it's between an opening `&` and terminating `;`. + // + // See: https://github.com/WordPress/gutenberg/issues/12448 + const isEquivalent = isEquivalentHTML( + '<h2>Test & Test</h2><h2>Test & Test</h2>', + '<h2>Test & Test</h2><h2>Test & Test</h2>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return false when more tokens in first', () => { + const isEquivalent = isEquivalentHTML( + '<div>Hello</div>', + '<div>Hello' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return false when more tokens in second', () => { + const isEquivalent = isEquivalentHTML( + '<div>Hello', + '<div>Hello</div>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return true when first has trailing whitespace', () => { + const isEquivalent = isEquivalentHTML( + '<div>Hello</div> ', + '<div>Hello</div>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return true when second has trailing whitespace', () => { + const isEquivalent = isEquivalentHTML( + '<div>Hello</div>', + '<div>Hello</div> ' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return true when difference of empty non-boolean, non-enumerated attribute', () => { + const isEquivalent = isEquivalentHTML( + '<div class="">Hello</div>', + '<div>Hello</div>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return false when difference of empty boolean attribute', () => { + const isEquivalent = isEquivalentHTML( + '<input disabled>', + '<input>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return false when difference of empty enumerated attribute', () => { + const isEquivalent = isEquivalentHTML( + '<div contenteditable>', + '<div>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return false when difference of data- attribute', () => { + const isEquivalent = isEquivalentHTML( + '<div data-foo>', + '<div>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return false when difference of boolean attribute', () => { + const isEquivalent = isEquivalentHTML( + '<video controls></video>', + '<video></video>' + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return true when same boolean attribute', () => { + const isEquivalent = isEquivalentHTML( + '<video controls></video>', + '<video controls></video>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return true when effectively same boolean attribute', () => { + const isEquivalent = isEquivalentHTML( + '<video controls></video>', + '<video controls=""></video>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return true when comparing empty strings', () => { + const isEquivalent = isEquivalentHTML( + '', + '', + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return false if supplied malformed HTML', () => { + const isEquivalent = isEquivalentHTML( + '<blockquote class="wp-block-quote">fsdfsdfsd<p>fdsfsdfsdd</pfd fd fd></blockquote>', + '<blockquote class="wp-block-quote">fsdfsdfsd<p>fdsfsdfsdd</p></blockquote>', + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return false if supplied two sets of malformed HTML', () => { + const isEquivalent = isEquivalentHTML( + '<div>fsdfsdfsd<p>fdsfsdfsdd</pfd fd fd></div>', + '<blockquote>fsdfsdfsd<p>fdsfsdfsdd</p a></blockquote>', + ); + + expect( console ).toHaveWarned(); + expect( isEquivalent ).toBe( false ); + } ); + + it( 'should return true when comparing self-closing and normal tags', () => { + let isEquivalent = isEquivalentHTML( + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none" />', + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"></path>' + ); + + expect( isEquivalent ).toBe( true ); + + isEquivalent = isEquivalentHTML( + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"></path>', + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none" />' + ); + + expect( isEquivalent ).toBe( true ); + } ); + + it( 'should return true when comparing self-closing and normal tags, ignoring trailing space', () => { + let isEquivalent = isEquivalentHTML( + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"/>', + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"></path>' + ); + + expect( isEquivalent ).toBe( true ); + + isEquivalent = isEquivalentHTML( + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"></path>', + '<path d="M0,0h24v24H0V0z M0,0h24v24H0V0z" fill="none"/>' + ); + + expect( isEquivalent ).toBe( true ); + } ); + } ); + + describe( 'isValidBlockContent()', () => { + it( 'returns false if block is not valid', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const isValid = isValidBlockContent( + 'core/test-block', + { fruit: 'Bananas' }, + 'Apples' + ); + + expect( console ).toHaveWarned(); + expect( console ).toHaveErrored(); + expect( isValid ).toBe( false ); + } ); + + it( 'returns false if error occurs while generating block save', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + save() { + throw new Error(); + }, + } ); + + const isValid = isValidBlockContent( + 'core/test-block', + { fruit: 'Bananas' }, + 'Bananas' + ); + + expect( console ).toHaveErrored(); + expect( isValid ).toBe( false ); + } ); + + it( 'returns true is block is valid', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const isValid = isValidBlockContent( + 'core/test-block', + { fruit: 'Bananas' }, + 'Bananas' + ); + + expect( isValid ).toBe( true ); + } ); + + it( 'works also when block type object is passed as object', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const isValid = isValidBlockContent( + getBlockType( 'core/test-block' ), + { fruit: 'Bananas' }, + 'Bananas' + ); + + expect( isValid ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/api/utils.js b/gb-src/packages/blocks/src/api/utils.js new file mode 100644 index 0000000000000..99dc98b5c3204 --- /dev/null +++ b/gb-src/packages/blocks/src/api/utils.js @@ -0,0 +1,123 @@ +/** + * External dependencies + */ +import { every, has, isFunction, isString } from 'lodash'; +import { default as tinycolor, mostReadable } from 'tinycolor2'; + +/** + * WordPress dependencies + */ +import { Component, isValidElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getBlockType, getDefaultBlockName } from './registration'; +import { createBlock } from './factory'; + +/** + * Array of icon colors containing a color to be used if the icon color + * was not explicitly set but the icon background color was. + * + * @type {Object} + */ +const ICON_COLORS = [ '#191e23', '#f8f9f9' ]; + +/** + * Determines whether the block is a default block + * and its attributes are equal to the default attributes + * which means the block is unmodified. + * + * @param {WPBlock} block Block Object + * + * @return {boolean} Whether the block is an unmodified default block + */ +export function isUnmodifiedDefaultBlock( block ) { + const defaultBlockName = getDefaultBlockName(); + if ( block.name !== defaultBlockName ) { + return false; + } + + // Cache a created default block if no cache exists or the default block + // name changed. + if ( + ! isUnmodifiedDefaultBlock.block || + isUnmodifiedDefaultBlock.block.name !== defaultBlockName + ) { + isUnmodifiedDefaultBlock.block = createBlock( defaultBlockName ); + } + + const newDefaultBlock = isUnmodifiedDefaultBlock.block; + const blockType = getBlockType( defaultBlockName ); + + return every( blockType.attributes, ( value, key ) => + newDefaultBlock.attributes[ key ] === block.attributes[ key ] + ); +} + +/** + * Function that checks if the parameter is a valid icon. + * + * @param {*} icon Parameter to be checked. + * + * @return {boolean} True if the parameter is a valid icon and false otherwise. + */ + +export function isValidIcon( icon ) { + return !! icon && ( + isString( icon ) || + isValidElement( icon ) || + isFunction( icon ) || + icon instanceof Component + ); +} + +/** + * Function that receives an icon as set by the blocks during the registration + * and returns a new icon object that is normalized so we can rely on just on possible icon structure + * in the codebase. + * + * @param {WPBlockTypeIconRender} icon Render behavior of a block type icon; + * one of a Dashicon slug, an element, or a + * component. + * + * @return {WPBlockTypeIconDescriptor} Object describing the icon. + */ +export function normalizeIconObject( icon ) { + if ( isValidIcon( icon ) ) { + return { src: icon }; + } + + if ( has( icon, [ 'background' ] ) ) { + const tinyBgColor = tinycolor( icon.background ); + + return { + ...icon, + foreground: icon.foreground ? icon.foreground : mostReadable( + tinyBgColor, + ICON_COLORS, + { includeFallbackColors: true, level: 'AA', size: 'large' } + ).toHexString(), + shadowColor: tinyBgColor.setAlpha( 0.3 ).toRgbString(), + }; + } + + return icon; +} + +/** + * Normalizes block type passed as param. When string is passed then + * it converts it to the matching block type object. + * It passes the original object otherwise. + * + * @param {string|Object} blockTypeOrName Block type or name. + * + * @return {?Object} Block type. + */ +export function normalizeBlockType( blockTypeOrName ) { + if ( isString( blockTypeOrName ) ) { + return getBlockType( blockTypeOrName ); + } + + return blockTypeOrName; +} diff --git a/gb-src/packages/blocks/src/api/validation/index.js b/gb-src/packages/blocks/src/api/validation/index.js new file mode 100644 index 0000000000000..01e5f0857a1ea --- /dev/null +++ b/gb-src/packages/blocks/src/api/validation/index.js @@ -0,0 +1,654 @@ +/** + * External dependencies + */ +import { Tokenizer } from 'simple-html-tokenizer'; +import { + identity, + xor, + fromPairs, + isEqual, + includes, + stubTrue, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { createLogger, createQueuedLogger } from './logger'; +import { getSaveContent } from '../serializer'; +import { normalizeBlockType } from '../utils'; + +/** + * Globally matches any consecutive whitespace + * + * @type {RegExp} + */ +const REGEXP_WHITESPACE = /[\t\n\r\v\f ]+/g; + +/** + * Matches a string containing only whitespace + * + * @type {RegExp} + */ +const REGEXP_ONLY_WHITESPACE = /^[\t\n\r\v\f ]*$/; + +/** + * Matches a CSS URL type value + * + * @type {RegExp} + */ +const REGEXP_STYLE_URL_TYPE = /^url\s*\(['"\s]*(.*?)['"\s]*\)$/; + +/** + * Boolean attributes are attributes whose presence as being assigned is + * meaningful, even if only empty. + * + * See: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes + * Extracted from: https://html.spec.whatwg.org/multipage/indices.html#attributes-3 + * + * Object.keys( [ ...document.querySelectorAll( '#attributes-1 > tbody > tr' ) ] + * .filter( ( tr ) => tr.lastChild.textContent.indexOf( 'Boolean attribute' ) !== -1 ) + * .reduce( ( result, tr ) => Object.assign( result, { + * [ tr.firstChild.textContent.trim() ]: true + * } ), {} ) ).sort(); + * + * @type {Array} + */ +const BOOLEAN_ATTRIBUTES = [ + 'allowfullscreen', + 'allowpaymentrequest', + 'allowusermedia', + 'async', + 'autofocus', + 'autoplay', + 'checked', + 'controls', + 'default', + 'defer', + 'disabled', + 'download', + 'formnovalidate', + 'hidden', + 'ismap', + 'itemscope', + 'loop', + 'multiple', + 'muted', + 'nomodule', + 'novalidate', + 'open', + 'playsinline', + 'readonly', + 'required', + 'reversed', + 'selected', + 'typemustmatch', +]; + +/** + * Enumerated attributes are attributes which must be of a specific value form. + * Like boolean attributes, these are meaningful if specified, even if not of a + * valid enumerated value. + * + * See: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#enumerated-attribute + * Extracted from: https://html.spec.whatwg.org/multipage/indices.html#attributes-3 + * + * Object.keys( [ ...document.querySelectorAll( '#attributes-1 > tbody > tr' ) ] + * .filter( ( tr ) => /^("(.+?)";?\s*)+/.test( tr.lastChild.textContent.trim() ) ) + * .reduce( ( result, tr ) => Object.assign( result, { + * [ tr.firstChild.textContent.trim() ]: true + * } ), {} ) ).sort(); + * + * @type {Array} + */ +const ENUMERATED_ATTRIBUTES = [ + 'autocapitalize', + 'autocomplete', + 'charset', + 'contenteditable', + 'crossorigin', + 'decoding', + 'dir', + 'draggable', + 'enctype', + 'formenctype', + 'formmethod', + 'http-equiv', + 'inputmode', + 'kind', + 'method', + 'preload', + 'scope', + 'shape', + 'spellcheck', + 'translate', + 'type', + 'wrap', +]; + +/** + * Meaningful attributes are those who cannot be safely ignored when omitted in + * one HTML markup string and not another. + * + * @type {Array} + */ +const MEANINGFUL_ATTRIBUTES = [ + ...BOOLEAN_ATTRIBUTES, + ...ENUMERATED_ATTRIBUTES, +]; + +/** + * Array of functions which receive a text string on which to apply normalizing + * behavior for consideration in text token equivalence, carefully ordered from + * least-to-most expensive operations. + * + * @type {Array} + */ +const TEXT_NORMALIZATIONS = [ + identity, + getTextWithCollapsedWhitespace, +]; + +/** + * Regular expression matching a named character reference. In lieu of bundling + * a full set of references, the pattern covers the minimal necessary to test + * positively against the full set. + * + * "The ampersand must be followed by one of the names given in the named + * character references section, using the same case." + * + * Tested aginst "12.5 Named character references": + * + * ``` + * const references = [ ...document.querySelectorAll( + * '#named-character-references-table tr[id^=entity-] td:first-child' + * ) ].map( ( code ) => code.textContent ) + * references.every( ( reference ) => /^[\da-z]+$/i.test( reference ) ) + * ``` + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#character-references + * @see https://html.spec.whatwg.org/multipage/named-characters.html#named-character-references + * + * @type {RegExp} + */ +const REGEXP_NAMED_CHARACTER_REFERENCE = /^[\da-z]+$/i; + +/** + * Regular expression matching a decimal character reference. + * + * "The ampersand must be followed by a U+0023 NUMBER SIGN character (#), + * followed by one or more ASCII digits, representing a base-ten integer" + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#character-references + * + * @type {RegExp} + */ +const REGEXP_DECIMAL_CHARACTER_REFERENCE = /^#\d+$/; + +/** + * Regular expression matching a hexadecimal character reference. + * + * "The ampersand must be followed by a U+0023 NUMBER SIGN character (#), which + * must be followed by either a U+0078 LATIN SMALL LETTER X character (x) or a + * U+0058 LATIN CAPITAL LETTER X character (X), which must then be followed by + * one or more ASCII hex digits, representing a hexadecimal integer" + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#character-references + * + * @type {RegExp} + */ +const REGEXP_HEXADECIMAL_CHARACTER_REFERENCE = /^#x[\da-f]+$/i; + +/** + * Returns true if the given string is a valid character reference segment, or + * false otherwise. The text should be stripped of `&` and `;` demarcations. + * + * @param {string} text Text to test. + * + * @return {boolean} Whether text is valid character reference. + */ +export function isValidCharacterReference( text ) { + return ( + REGEXP_NAMED_CHARACTER_REFERENCE.test( text ) || + REGEXP_DECIMAL_CHARACTER_REFERENCE.test( text ) || + REGEXP_HEXADECIMAL_CHARACTER_REFERENCE.test( text ) + ); +} + +/** + * Subsitute EntityParser class for `simple-html-tokenizer` which uses the + * implementation of `decodeEntities` from `html-entities`, in order to avoid + * bundling a massive named character reference. + * + * @see https://github.com/tildeio/simple-html-tokenizer/tree/master/src/entity-parser.ts + */ +export class DecodeEntityParser { + /** + * Returns a substitute string for an entity string sequence between `&` + * and `;`, or undefined if no substitution should occur. + * + * @param {string} entity Entity fragment discovered in HTML. + * + * @return {?string} Entity substitute value. + */ + parse( entity ) { + if ( isValidCharacterReference( entity ) ) { + return decodeEntities( '&' + entity + ';' ); + } + } +} + +/** + * Given a specified string, returns an array of strings split by consecutive + * whitespace, ignoring leading or trailing whitespace. + * + * @param {string} text Original text. + * + * @return {string[]} Text pieces split on whitespace. + */ +export function getTextPiecesSplitOnWhitespace( text ) { + return text.trim().split( REGEXP_WHITESPACE ); +} + +/** + * Given a specified string, returns a new trimmed string where all consecutive + * whitespace is collapsed to a single space. + * + * @param {string} text Original text. + * + * @return {string} Trimmed text with consecutive whitespace collapsed. + */ +export function getTextWithCollapsedWhitespace( text ) { + // This is an overly simplified whitespace comparison. The specification is + // more prescriptive of whitespace behavior in inline and block contexts. + // + // See: https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 + return getTextPiecesSplitOnWhitespace( text ).join( ' ' ); +} + +/** + * Returns attribute pairs of the given StartTag token, including only pairs + * where the value is non-empty or the attribute is a boolean attribute, an + * enumerated attribute, or a custom data- attribute. + * + * @see MEANINGFUL_ATTRIBUTES + * + * @param {Object} token StartTag token. + * + * @return {Array[]} Attribute pairs. + */ +export function getMeaningfulAttributePairs( token ) { + return token.attributes.filter( ( pair ) => { + const [ key, value ] = pair; + return ( + value || + key.indexOf( 'data-' ) === 0 || + includes( MEANINGFUL_ATTRIBUTES, key ) + ); + } ); +} + +/** + * Returns true if two text tokens (with `chars` property) are equivalent, or + * false otherwise. + * + * @param {Object} actual Actual token. + * @param {Object} expected Expected token. + * @param {Object} logger Validation logger object. + * + * @return {boolean} Whether two text tokens are equivalent. + */ +export function isEquivalentTextTokens( actual, expected, logger = createLogger() ) { + // This function is intentionally written as syntactically "ugly" as a hot + // path optimization. Text is progressively normalized in order from least- + // to-most operationally expensive, until the earliest point at which text + // can be confidently inferred as being equal. + let actualChars = actual.chars; + let expectedChars = expected.chars; + + for ( let i = 0; i < TEXT_NORMALIZATIONS.length; i++ ) { + const normalize = TEXT_NORMALIZATIONS[ i ]; + + actualChars = normalize( actualChars ); + expectedChars = normalize( expectedChars ); + + if ( actualChars === expectedChars ) { + return true; + } + } + + logger.warning( 'Expected text `%s`, saw `%s`.', expected.chars, actual.chars ); + + return false; +} + +/** + * Given a style value, returns a normalized style value for strict equality + * comparison. + * + * @param {string} value Style value. + * + * @return {string} Normalized style value. + */ +export function getNormalizedStyleValue( value ) { + return value + // Normalize URL type to omit whitespace or quotes + .replace( REGEXP_STYLE_URL_TYPE, 'url($1)' ); +} + +/** + * Given a style attribute string, returns an object of style properties. + * + * @param {string} text Style attribute. + * + * @return {Object} Style properties. + */ +export function getStyleProperties( text ) { + const pairs = text + // Trim ending semicolon (avoid including in split) + .replace( /;?\s*$/, '' ) + // Split on property assignment + .split( ';' ) + // For each property assignment... + .map( ( style ) => { + // ...split further into key-value pairs + const [ key, ...valueParts ] = style.split( ':' ); + const value = valueParts.join( ':' ); + + return [ + key.trim(), + getNormalizedStyleValue( value.trim() ), + ]; + } ); + + return fromPairs( pairs ); +} + +/** + * Attribute-specific equality handlers + * + * @type {Object} + */ +export const isEqualAttributesOfName = { + class: ( actual, expected ) => { + // Class matches if members are the same, even if out of order or + // superfluous whitespace between. + return ! xor( ...[ actual, expected ].map( getTextPiecesSplitOnWhitespace ) ).length; + }, + style: ( actual, expected ) => { + return isEqual( ...[ actual, expected ].map( getStyleProperties ) ); + }, + // For each boolean attribute, mere presence of attribute in both is enough + // to assume equivalence. + ...fromPairs( BOOLEAN_ATTRIBUTES.map( ( attribute ) => [ attribute, stubTrue ] ) ), +}; + +/** + * Given two sets of attribute tuples, returns true if the attribute sets are + * equivalent. + * + * @param {Array[]} actual Actual attributes tuples. + * @param {Array[]} expected Expected attributes tuples. + * @param {Object} logger Validation logger object. + * + * @return {boolean} Whether attributes are equivalent. + */ +export function isEqualTagAttributePairs( actual, expected, logger = createLogger() ) { + // Attributes is tokenized as tuples. Their lengths should match. This also + // avoids us needing to check both attributes sets, since if A has any keys + // which do not exist in B, we know the sets to be different. + if ( actual.length !== expected.length ) { + logger.warning( 'Expected attributes %o, instead saw %o.', expected, actual ); + return false; + } + + // Convert tuples to object for ease of lookup + const [ actualAttributes, expectedAttributes ] = [ actual, expected ].map( fromPairs ); + + for ( const name in actualAttributes ) { + // As noted above, if missing member in B, assume different + if ( ! expectedAttributes.hasOwnProperty( name ) ) { + logger.warning( 'Encountered unexpected attribute `%s`.', name ); + return false; + } + + const actualValue = actualAttributes[ name ]; + const expectedValue = expectedAttributes[ name ]; + + const isEqualAttributes = isEqualAttributesOfName[ name ]; + if ( isEqualAttributes ) { + // Defer custom attribute equality handling + if ( ! isEqualAttributes( actualValue, expectedValue ) ) { + logger.warning( 'Expected attribute `%s` of value `%s`, saw `%s`.', name, expectedValue, actualValue ); + return false; + } + } else if ( actualValue !== expectedValue ) { + // Otherwise strict inequality should bail + logger.warning( 'Expected attribute `%s` of value `%s`, saw `%s`.', name, expectedValue, actualValue ); + return false; + } + } + + return true; +} + +/** + * Token-type-specific equality handlers + * + * @type {Object} + */ +export const isEqualTokensOfType = { + StartTag: ( actual, expected, logger = createLogger() ) => { + if ( actual.tagName !== expected.tagName ) { + logger.warning( 'Expected tag name `%s`, instead saw `%s`.', expected.tagName, actual.tagName ); + return false; + } + + return isEqualTagAttributePairs( + ...[ actual, expected ].map( getMeaningfulAttributePairs ), + logger + ); + }, + Chars: isEquivalentTextTokens, + Comment: isEquivalentTextTokens, +}; + +/** + * Given an array of tokens, returns the first token which is not purely + * whitespace. + * + * Mutates the tokens array. + * + * @param {Object[]} tokens Set of tokens to search. + * + * @return {Object} Next non-whitespace token. + */ +export function getNextNonWhitespaceToken( tokens ) { + let token; + while ( ( token = tokens.shift() ) ) { + if ( token.type !== 'Chars' ) { + return token; + } + + if ( ! REGEXP_ONLY_WHITESPACE.test( token.chars ) ) { + return token; + } + } +} + +/** + * Tokenize an HTML string, gracefully handling any errors thrown during + * underlying tokenization. + * + * @param {string} html HTML string to tokenize. + * @param {Object} logger Validation logger object. + * + * @return {Object[]|null} Array of valid tokenized HTML elements, or null on error + */ +function getHTMLTokens( html, logger = createLogger() ) { + try { + return new Tokenizer( new DecodeEntityParser() ).tokenize( html ); + } catch ( e ) { + logger.warning( 'Malformed HTML detected: %s', html ); + } + + return null; +} + +/** + * Returns true if the next HTML token closes the current token. + * + * @param {Object} currentToken Current token to compare with. + * @param {Object|undefined} nextToken Next token to compare against. + * + * @return {boolean} true if `nextToken` closes `currentToken`, false otherwise + */ +export function isClosedByToken( currentToken, nextToken ) { + // Ensure this is a self closed token + if ( ! currentToken.selfClosing ) { + return false; + } + + // Check token names and determine if nextToken is the closing tag for currentToken + if ( nextToken && nextToken.tagName === currentToken.tagName && nextToken.type === 'EndTag' ) { + return true; + } + + return false; +} + +/** + * Returns true if the given HTML strings are effectively equivalent, or + * false otherwise. Invalid HTML is not considered equivalent, even if the + * strings directly match. + * + * @param {string} actual Actual HTML string. + * @param {string} expected Expected HTML string. + * @param {Object} logger Validation logger object. + * + * @return {boolean} Whether HTML strings are equivalent. + */ +export function isEquivalentHTML( actual, expected, logger = createLogger() ) { + // Tokenize input content and reserialized save content + const [ actualTokens, expectedTokens ] = [ actual, expected ].map( + ( html ) => getHTMLTokens( html, logger ) + ); + + // If either is malformed then stop comparing - the strings are not equivalent + if ( ! actualTokens || ! expectedTokens ) { + return false; + } + + let actualToken, expectedToken; + while ( ( actualToken = getNextNonWhitespaceToken( actualTokens ) ) ) { + expectedToken = getNextNonWhitespaceToken( expectedTokens ); + + // Inequal if exhausted all expected tokens + if ( ! expectedToken ) { + logger.warning( 'Expected end of content, instead saw %o.', actualToken ); + return false; + } + + // Inequal if next non-whitespace token of each set are not same type + if ( actualToken.type !== expectedToken.type ) { + logger.warning( 'Expected token of type `%s` (%o), instead saw `%s` (%o).', expectedToken.type, expectedToken, actualToken.type, actualToken ); + return false; + } + + // Defer custom token type equality handling, otherwise continue and + // assume as equal + const isEqualTokens = isEqualTokensOfType[ actualToken.type ]; + if ( isEqualTokens && ! isEqualTokens( actualToken, expectedToken, logger ) ) { + return false; + } + + // Peek at the next tokens (actual and expected) to see if they close + // a self-closing tag + if ( isClosedByToken( actualToken, expectedTokens[ 0 ] ) ) { + // Consume the next expected token that closes the current actual + // self-closing token + getNextNonWhitespaceToken( expectedTokens ); + } else if ( isClosedByToken( expectedToken, actualTokens[ 0 ] ) ) { + // Consume the next actual token that closes the current expected + // self-closing token + getNextNonWhitespaceToken( actualTokens ); + } + } + + if ( ( expectedToken = getNextNonWhitespaceToken( expectedTokens ) ) ) { + // If any non-whitespace tokens remain in expected token set, this + // indicates inequality + logger.warning( 'Expected %o, instead saw end of content.', expectedToken ); + return false; + } + + return true; +} + +/** + * Returns an object with `isValid` property set to `true` if the parsed block + * is valid given the input content. A block is considered valid if, when serialized + * with assumed attributes, the content matches the original value. If block is + * invalid, this function returns all validations issues as well. + * + * @param {string|Object} blockTypeOrName Block type. + * @param {Object} attributes Parsed block attributes. + * @param {string} originalBlockContent Original block content. + * @param {Object} logger Validation logger object. + * + * @return {Object} Whether block is valid and contains validation messages. + */ +export function getBlockContentValidationResult( blockTypeOrName, attributes, originalBlockContent, logger = createQueuedLogger() ) { + const blockType = normalizeBlockType( blockTypeOrName ); + let generatedBlockContent; + try { + generatedBlockContent = getSaveContent( blockType, attributes ); + } catch ( error ) { + logger.error( 'Block validation failed because an error occurred while generating block content:\n\n%s', error.toString() ); + + return { + isValid: false, + validationIssues: logger.getItems(), + }; + } + + const isValid = isEquivalentHTML( originalBlockContent, generatedBlockContent, logger ); + if ( ! isValid ) { + logger.error( + 'Block validation failed for `%s` (%o).\n\nContent generated by `save` function:\n\n%s\n\nContent retrieved from post body:\n\n%s', + blockType.name, + blockType, + generatedBlockContent, + originalBlockContent + ); + } + + return { + isValid, + validationIssues: logger.getItems(), + }; +} + +/** + * Returns true if the parsed block is valid given the input content. A block + * is considered valid if, when serialized with assumed attributes, the content + * matches the original value. + * + * Logs to console in development environments when invalid. + * + * @param {string|Object} blockTypeOrName Block type. + * @param {Object} attributes Parsed block attributes. + * @param {string} originalBlockContent Original block content. + * + * @return {boolean} Whether block is valid. + */ +export function isValidBlockContent( blockTypeOrName, attributes, originalBlockContent ) { + const { isValid } = getBlockContentValidationResult( blockTypeOrName, attributes, originalBlockContent, createLogger() ); + + return isValid; +} diff --git a/gb-src/packages/blocks/src/api/validation/logger.js b/gb-src/packages/blocks/src/api/validation/logger.js new file mode 100644 index 0000000000000..ce7f3f6ea22c4 --- /dev/null +++ b/gb-src/packages/blocks/src/api/validation/logger.js @@ -0,0 +1,56 @@ +export function createLogger() { + /** + * Creates a log handler with block validation prefix. + * + * @param {Function} logger Original logger function. + * + * @return {Function} Augmented logger function. + */ + function createLogHandler( logger ) { + let log = ( message, ...args ) => logger( 'Block validation: ' + message, ...args ); + + // In test environments, pre-process the sprintf message to improve + // readability of error messages. We'd prefer to avoid pulling in this + // dependency in runtime environments, and it can be dropped by a combo + // of Webpack env substitution + UglifyJS dead code elimination. + if ( process.env.NODE_ENV === 'test' ) { + // eslint-disable-next-line import/no-extraneous-dependencies + log = ( ...args ) => logger( require( 'sprintf-js' ).sprintf( ...args ) ); + } + + return log; + } + + return { + // eslint-disable-next-line no-console + error: createLogHandler( console.error ), + // eslint-disable-next-line no-console + warning: createLogHandler( console.warn ), + getItems() { + return []; + }, + }; +} + +export function createQueuedLogger() { + /** + * The list of enqueued log actions to print. + * + * @type {Array} + */ + const queue = []; + + const logger = createLogger(); + + return { + error( ...args ) { + queue.push( { log: logger.error, args } ); + }, + warning( ...args ) { + queue.push( { log: logger.warning, args } ); + }, + getItems() { + return queue; + }, + }; +} diff --git a/gb-src/packages/blocks/src/block-content-provider/README.md b/gb-src/packages/blocks/src/block-content-provider/README.md new file mode 100644 index 0000000000000..45ad712a49e8b --- /dev/null +++ b/gb-src/packages/blocks/src/block-content-provider/README.md @@ -0,0 +1,12 @@ +BlockContentProvider +==================== + +An internal block component used in block content serialization to inject nested block content within the `save` implementation of the ancestor component in which it is nested. The component provides a pre-bound `BlockContent` component via context, which is used by the developer-facing `InnerBlocks.Content` component to render block content. + +## Usage + +```jsx +<BlockContentProvider innerBlocks={ innerBlocks }> + { blockSaveElement } +</BlockContentProvider> +``` diff --git a/gb-src/packages/blocks/src/block-content-provider/index.js b/gb-src/packages/blocks/src/block-content-provider/index.js new file mode 100644 index 0000000000000..352550a87be80 --- /dev/null +++ b/gb-src/packages/blocks/src/block-content-provider/index.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { createContext, RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { serialize } from '../api'; + +const { Consumer, Provider } = createContext( () => {} ); + +/** + * An internal block component used in block content serialization to inject + * nested block content within the `save` implementation of the ancestor + * component in which it is nested. The component provides a pre-bound + * `BlockContent` component via context, which is used by the developer-facing + * `InnerBlocks.Content` component to render block content. + * + * @example + * + * ```jsx + * <BlockContentProvider innerBlocks={ innerBlocks }> + * { blockSaveElement } + * </BlockContentProvider> + * ``` + * + * @return {WPElement} Element with BlockContent injected via context. + */ +const BlockContentProvider = ( { children, innerBlocks } ) => { + const BlockContent = () => { + // Value is an array of blocks, so defer to block serializer + const html = serialize( innerBlocks, { isInnerBlocks: true } ); + + // Use special-cased raw HTML tag to avoid default escaping + return <RawHTML>{ html }</RawHTML>; + }; + + return ( + <Provider value={ BlockContent }> + { children } + </Provider> + ); +}; + +/** + * A Higher Order Component used to inject BlockContent using context to the + * wrapped component. + * + * @return {Component} Enhanced component with injected BlockContent as prop. + */ +export const withBlockContentContext = createHigherOrderComponent( ( OriginalComponent ) => { + return ( props ) => ( + <Consumer> + { ( context ) => ( + <OriginalComponent + { ...props } + BlockContent={ context } + /> + ) } + </Consumer> + ); +}, 'withBlockContentContext' ); + +export default BlockContentProvider; diff --git a/gb-src/packages/blocks/src/index.js b/gb-src/packages/blocks/src/index.js new file mode 100644 index 0000000000000..579665d14b853 --- /dev/null +++ b/gb-src/packages/blocks/src/index.js @@ -0,0 +1,17 @@ +// A "block" is the abstract term used to describe units of markup that, +// when composed together, form the content or layout of a page. +// The API for blocks is exposed via `wp.blocks`. +// +// Supported blocks are registered by calling `registerBlockType`. Once registered, +// the block is made available as an option to the editor interface. +// +// Blocks are inferred from the HTML source of a post through a parsing mechanism +// and then stored as objects in state, from which it is then rendered for editing. + +/** + * Internal dependencies + */ +import './store'; + +export * from './api'; +export { withBlockContentContext } from './block-content-provider'; diff --git a/gb-src/packages/blocks/src/store/actions.js b/gb-src/packages/blocks/src/store/actions.js new file mode 100644 index 0000000000000..220ae9be0ea0d --- /dev/null +++ b/gb-src/packages/blocks/src/store/actions.js @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * Returns an action object used in signalling that block types have been added. + * + * @param {Array|Object} blockTypes Block types received. + * + * @return {Object} Action object. + */ +export function addBlockTypes( blockTypes ) { + return { + type: 'ADD_BLOCK_TYPES', + blockTypes: castArray( blockTypes ), + }; +} + +/** + * Returns an action object used to remove a registered block type. + * + * @param {string|Array} names Block name. + * + * @return {Object} Action object. + */ +export function removeBlockTypes( names ) { + return { + type: 'REMOVE_BLOCK_TYPES', + names: castArray( names ), + }; +} + +/** + * Returns an action object used in signalling that new block styles have been added. + * + * @param {string} blockName Block name. + * @param {Array|Object} styles Block styles. + * + * @return {Object} Action object. + */ +export function addBlockStyles( blockName, styles ) { + return { + type: 'ADD_BLOCK_STYLES', + styles: castArray( styles ), + blockName, + }; +} + +/** + * Returns an action object used in signalling that block styles have been removed. + * + * @param {string} blockName Block name. + * @param {Array|string} styleNames Block style names. + * + * @return {Object} Action object. + */ +export function removeBlockStyles( blockName, styleNames ) { + return { + type: 'REMOVE_BLOCK_STYLES', + styleNames: castArray( styleNames ), + blockName, + }; +} + +/** + * Returns an action object used to set the default block name. + * + * @param {string} name Block name. + * + * @return {Object} Action object. + */ +export function setDefaultBlockName( name ) { + return { + type: 'SET_DEFAULT_BLOCK_NAME', + name, + }; +} + +/** + * Returns an action object used to set the name of the block used as a fallback + * for non-block content. + * + * @param {string} name Block name. + * + * @return {Object} Action object. + */ +export function setFreeformFallbackBlockName( name ) { + return { + type: 'SET_FREEFORM_FALLBACK_BLOCK_NAME', + name, + }; +} + +/** + * Returns an action object used to set the name of the block used as a fallback + * for unregistered blocks. + * + * @param {string} name Block name. + * + * @return {Object} Action object. + */ +export function setUnregisteredFallbackBlockName( name ) { + return { + type: 'SET_UNREGISTERED_FALLBACK_BLOCK_NAME', + name, + }; +} + +/** + * Returns an action object used to set the name of the block used + * when grouping other blocks + * eg: in "Group/Ungroup" interactions + * + * @param {string} name Block name. + * + * @return {Object} Action object. + */ +export function setGroupingBlockName( name ) { + return { + type: 'SET_GROUPING_BLOCK_NAME', + name, + }; +} + +/** + * Returns an action object used to set block categories. + * + * @param {Object[]} categories Block categories. + * + * @return {Object} Action object. + */ +export function setCategories( categories ) { + return { + type: 'SET_CATEGORIES', + categories, + }; +} + +/** + * Returns an action object used to update a category. + * + * @param {string} slug Block category slug. + * @param {Object} category Object containing the category properties that should be updated. + * + * @return {Object} Action object. + */ +export function updateCategory( slug, category ) { + return { + type: 'UPDATE_CATEGORY', + slug, + category, + }; +} diff --git a/gb-src/packages/blocks/src/store/index.js b/gb-src/packages/blocks/src/store/index.js new file mode 100644 index 0000000000000..4ba62857cb05c --- /dev/null +++ b/gb-src/packages/blocks/src/store/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; + +registerStore( 'core/blocks', { reducer, selectors, actions } ); diff --git a/gb-src/packages/blocks/src/store/reducer.js b/gb-src/packages/blocks/src/store/reducer.js new file mode 100644 index 0000000000000..405e60109adda --- /dev/null +++ b/gb-src/packages/blocks/src/store/reducer.js @@ -0,0 +1,170 @@ +/** + * External dependencies + */ +import { + filter, + find, + get, + isEmpty, + keyBy, + map, + mapValues, + omit, + uniqBy, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Module Constants + */ +export const DEFAULT_CATEGORIES = [ + { slug: 'common', title: __( 'Common Blocks' ) }, + { slug: 'formatting', title: __( 'Formatting' ) }, + { slug: 'layout', title: __( 'Layout Elements' ) }, + { slug: 'widgets', title: __( 'Widgets' ) }, + { slug: 'embed', title: __( 'Embeds' ) }, + { slug: 'reusable', title: __( 'Reusable Blocks' ) }, +]; + +/** + * Reducer managing the block types + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function blockTypes( state = {}, action ) { + switch ( action.type ) { + case 'ADD_BLOCK_TYPES': + return { + ...state, + ...keyBy( + map( action.blockTypes, ( blockType ) => omit( blockType, 'styles ' ) ), + 'name' + ), + }; + case 'REMOVE_BLOCK_TYPES': + return omit( state, action.names ); + } + + return state; +} + +/** + * Reducer managing the block style variations. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function blockStyles( state = {}, action ) { + switch ( action.type ) { + case 'ADD_BLOCK_TYPES': + return { + ...state, + ...mapValues( keyBy( action.blockTypes, 'name' ), ( blockType ) => { + return uniqBy( [ + ...get( blockType, [ 'styles' ], [] ), + ...get( state, [ blockType.name ], [] ), + ], ( style ) => style.name ); + } ), + }; + case 'ADD_BLOCK_STYLES': + return { + ...state, + [ action.blockName ]: uniqBy( [ + ...get( state, [ action.blockName ], [] ), + ...( action.styles ), + ], ( style ) => style.name ), + }; + case 'REMOVE_BLOCK_STYLES': + return { + ...state, + [ action.blockName ]: filter( + get( state, [ action.blockName ], [] ), + ( style ) => action.styleNames.indexOf( style.name ) === -1, + ), + }; + } + + return state; +} + +/** + * Higher-order Reducer creating a reducer keeping track of given block name. + * + * @param {string} setActionType Action type. + * + * @return {Function} Reducer. + */ +export function createBlockNameSetterReducer( setActionType ) { + return ( state = null, action ) => { + switch ( action.type ) { + case 'REMOVE_BLOCK_TYPES': + if ( action.names.indexOf( state ) !== -1 ) { + return null; + } + return state; + + case setActionType: + return action.name || null; + } + + return state; + }; +} + +export const defaultBlockName = createBlockNameSetterReducer( 'SET_DEFAULT_BLOCK_NAME' ); +export const freeformFallbackBlockName = createBlockNameSetterReducer( 'SET_FREEFORM_FALLBACK_BLOCK_NAME' ); +export const unregisteredFallbackBlockName = createBlockNameSetterReducer( 'SET_UNREGISTERED_FALLBACK_BLOCK_NAME' ); +export const groupingBlockName = createBlockNameSetterReducer( 'SET_GROUPING_BLOCK_NAME' ); + +/** + * Reducer managing the categories + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function categories( state = DEFAULT_CATEGORIES, action ) { + switch ( action.type ) { + case 'SET_CATEGORIES': + return action.categories || []; + case 'UPDATE_CATEGORY': { + if ( ! action.category || isEmpty( action.category ) ) { + return state; + } + const categoryToChange = find( state, [ 'slug', action.slug ] ); + if ( categoryToChange ) { + return map( state, ( category ) => { + if ( category.slug === action.slug ) { + return { + ...category, + ...action.category, + }; + } + return category; + } ); + } + } + } + return state; +} + +export default combineReducers( { + blockTypes, + blockStyles, + defaultBlockName, + freeformFallbackBlockName, + unregisteredFallbackBlockName, + groupingBlockName, + categories, +} ); diff --git a/gb-src/packages/blocks/src/store/selectors.js b/gb-src/packages/blocks/src/store/selectors.js new file mode 100644 index 0000000000000..cb9333e3f6ff1 --- /dev/null +++ b/gb-src/packages/blocks/src/store/selectors.js @@ -0,0 +1,241 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import { filter, get, includes, map, some, flow, deburr } from 'lodash'; + +/** + * Given a block name or block type object, returns the corresponding + * normalized block type object. + * + * @param {Object} state Blocks state. + * @param {(string|Object)} nameOrType Block name or type object + * + * @return {Object} Block type object. + */ +const getNormalizedBlockType = ( state, nameOrType ) => ( + 'string' === typeof nameOrType ? + getBlockType( state, nameOrType ) : + nameOrType +); + +/** + * Returns all the available block types. + * + * @param {Object} state Data state. + * + * @return {Array} Block Types. + */ +export const getBlockTypes = createSelector( + ( state ) => Object.values( state.blockTypes ), + ( state ) => [ + state.blockTypes, + ] +); + +/** + * Returns a block type by name. + * + * @param {Object} state Data state. + * @param {string} name Block type name. + * + * @return {Object?} Block Type. + */ +export function getBlockType( state, name ) { + return state.blockTypes[ name ]; +} + +/** + * Returns block styles by block name. + * + * @param {Object} state Data state. + * @param {string} name Block type name. + * + * @return {Array?} Block Styles. + */ +export function getBlockStyles( state, name ) { + return state.blockStyles[ name ]; +} + +/** + * Returns all the available categories. + * + * @param {Object} state Data state. + * + * @return {Array} Categories list. + */ +export function getCategories( state ) { + return state.categories; +} + +/** + * Returns the name of the default block name. + * + * @param {Object} state Data state. + * + * @return {string?} Default block name. + */ +export function getDefaultBlockName( state ) { + return state.defaultBlockName; +} + +/** + * Returns the name of the block for handling non-block content. + * + * @param {Object} state Data state. + * + * @return {string?} Name of the block for handling non-block content. + */ +export function getFreeformFallbackBlockName( state ) { + return state.freeformFallbackBlockName; +} + +/** + * Returns the name of the block for handling unregistered blocks. + * + * @param {Object} state Data state. + * + * @return {string?} Name of the block for handling unregistered blocks. + */ +export function getUnregisteredFallbackBlockName( state ) { + return state.unregisteredFallbackBlockName; +} + +/** + * Returns the name of the block for handling unregistered blocks. + * + * @param {Object} state Data state. + * + * @return {string?} Name of the block for handling unregistered blocks. + */ +export function getGroupingBlockName( state ) { + return state.groupingBlockName; +} + +/** + * Returns an array with the child blocks of a given block. + * + * @param {Object} state Data state. + * @param {string} blockName Block type name. + * + * @return {Array} Array of child block names. + */ +export const getChildBlockNames = createSelector( + ( state, blockName ) => { + return map( + filter( state.blockTypes, ( blockType ) => { + return includes( blockType.parent, blockName ); + } ), + ( { name } ) => name + ); + }, + ( state ) => [ + state.blockTypes, + ] +); + +/** + * Returns the block support value for a feature, if defined. + * + * @param {Object} state Data state. + * @param {(string|Object)} nameOrType Block name or type object + * @param {string} feature Feature to retrieve + * @param {*} defaultSupports Default value to return if not + * explicitly defined + * + * @return {?*} Block support value + */ +export const getBlockSupport = ( state, nameOrType, feature, defaultSupports ) => { + const blockType = getNormalizedBlockType( state, nameOrType ); + + return get( blockType, [ + 'supports', + feature, + ], defaultSupports ); +}; + +/** + * Returns true if the block defines support for a feature, or false otherwise. + * + * @param {Object} state Data state. + * @param {(string|Object)} nameOrType Block name or type object. + * @param {string} feature Feature to test. + * @param {boolean} defaultSupports Whether feature is supported by + * default if not explicitly defined. + * + * @return {boolean} Whether block supports feature. + */ +export function hasBlockSupport( state, nameOrType, feature, defaultSupports ) { + return !! getBlockSupport( state, nameOrType, feature, defaultSupports ); +} + +/** + * Returns true if the block type by the given name or object value matches a + * search term, or false otherwise. + * + * @param {Object} state Blocks state. + * @param {(string|Object)} nameOrType Block name or type object. + * @param {string} searchTerm Search term by which to filter. + * + * @return {Object[]} Whether block type matches search term. + */ +export function isMatchingSearchTerm( state, nameOrType, searchTerm ) { + const blockType = getNormalizedBlockType( state, nameOrType ); + + const getNormalizedSearchTerm = flow( [ + // Disregard diacritics. + // Input: "média" + deburr, + + // Lowercase. + // Input: "MEDIA" + ( term ) => term.toLowerCase(), + + // Strip leading and trailing whitespace. + // Input: " media " + ( term ) => term.trim(), + ] ); + + const normalizedSearchTerm = getNormalizedSearchTerm( searchTerm ); + + const isSearchMatch = flow( [ + getNormalizedSearchTerm, + ( normalizedCandidate ) => includes( + normalizedCandidate, + normalizedSearchTerm + ), + ] ); + + return ( + isSearchMatch( blockType.title ) || + some( blockType.keywords, isSearchMatch ) || + isSearchMatch( blockType.category ) + ); +} + +/** + * Returns a boolean indicating if a block has child blocks or not. + * + * @param {Object} state Data state. + * @param {string} blockName Block type name. + * + * @return {boolean} True if a block contains child blocks and false otherwise. + */ +export const hasChildBlocks = ( state, blockName ) => { + return getChildBlockNames( state, blockName ).length > 0; +}; + +/** + * Returns a boolean indicating if a block has at least one child block with inserter support. + * + * @param {Object} state Data state. + * @param {string} blockName Block type name. + * + * @return {boolean} True if a block contains at least one child blocks with inserter support + * and false otherwise. + */ +export const hasChildBlocksWithInserterSupport = ( state, blockName ) => { + return some( getChildBlockNames( state, blockName ), ( childBlockName ) => { + return hasBlockSupport( state, childBlockName, 'inserter', true ); + } ); +}; diff --git a/gb-src/packages/blocks/src/store/test/reducer.js b/gb-src/packages/blocks/src/store/test/reducer.js new file mode 100644 index 0000000000000..346bb2e9aab1b --- /dev/null +++ b/gb-src/packages/blocks/src/store/test/reducer.js @@ -0,0 +1,337 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + blockStyles, + blockTypes, + categories, + defaultBlockName, + freeformFallbackBlockName, + unregisteredFallbackBlockName, + groupingBlockName, + DEFAULT_CATEGORIES, +} from '../reducer'; + +describe( 'blockTypes', () => { + it( 'should return an empty object as default state', () => { + expect( blockTypes( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should add a new block type', () => { + const original = deepFreeze( { + 'core/paragraph': { name: 'core/paragraph' }, + } ); + + const state = blockTypes( original, { + type: 'ADD_BLOCK_TYPES', + blockTypes: [ { name: 'core/code' } ], + } ); + + expect( state ).toEqual( { + 'core/paragraph': { name: 'core/paragraph' }, + 'core/code': { name: 'core/code' }, + } ); + } ); + + it( 'should remove block types', () => { + const original = deepFreeze( { + 'core/paragraph': { name: 'core/paragraph' }, + 'core/code': { name: 'core/code' }, + } ); + + const state = blockTypes( original, { + type: 'REMOVE_BLOCK_TYPES', + names: [ 'core/code' ], + } ); + + expect( state ).toEqual( { + 'core/paragraph': { name: 'core/paragraph' }, + } ); + } ); +} ); + +describe( 'blockStyles', () => { + it( 'should return an empty object as default state', () => { + expect( blockStyles( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should add a new block styles', () => { + const original = deepFreeze( {} ); + + let state = blockStyles( original, { + type: 'ADD_BLOCK_STYLES', + blockName: 'core/image', + styles: [ { name: 'fancy' } ], + } ); + + expect( state ).toEqual( { + 'core/image': [ + { name: 'fancy' }, + ], + } ); + + state = blockStyles( state, { + type: 'ADD_BLOCK_STYLES', + blockName: 'core/image', + styles: [ { name: 'lightbox' } ], + } ); + + expect( state ).toEqual( { + 'core/image': [ + { name: 'fancy' }, + { name: 'lightbox' }, + ], + } ); + } ); + + it( 'should add block styles when adding a block', () => { + const original = deepFreeze( { + 'core/image': [ + { name: 'fancy' }, + ], + } ); + + const state = blockStyles( original, { + type: 'ADD_BLOCK_TYPES', + blockTypes: [ { + name: 'core/image', + styles: [ + { name: 'original' }, + ], + } ], + } ); + + expect( state ).toEqual( { + 'core/image': [ + { name: 'original' }, + { name: 'fancy' }, + ], + } ); + } ); + + it( 'should remove block styles', () => { + const original = deepFreeze( { + 'core/image': [ + { name: 'fancy' }, + { name: 'lightbox' }, + ], + } ); + + const state = blockStyles( original, { + type: 'REMOVE_BLOCK_STYLES', + blockName: 'core/image', + styleNames: [ 'fancy' ], + } ); + + expect( state ).toEqual( { + 'core/image': [ + { name: 'lightbox' }, + ], + } ); + } ); +} ); + +describe( 'defaultBlockName', () => { + it( 'should return null as default state', () => { + expect( defaultBlockName( undefined, {} ) ).toBeNull(); + } ); + + it( 'should set the default block name', () => { + const state = defaultBlockName( null, { + type: 'SET_DEFAULT_BLOCK_NAME', + name: 'core/paragraph', + } ); + + expect( state ).toBe( 'core/paragraph' ); + } ); + + it( 'should reset the default block name', () => { + const state = defaultBlockName( 'core/code', { + type: 'REMOVE_BLOCK_TYPES', + names: [ 'core/code' ], + } ); + + expect( state ).toBeNull(); + } ); +} ); + +describe( 'freeformFallbackBlockName', () => { + it( 'should return null as default state', () => { + expect( freeformFallbackBlockName( undefined, {} ) ).toBeNull(); + } ); + + it( 'should set the freeform content fallback block name', () => { + const state = freeformFallbackBlockName( null, { + type: 'SET_FREEFORM_FALLBACK_BLOCK_NAME', + name: 'core/paragraph', + } ); + + expect( state ).toBe( 'core/paragraph' ); + } ); + + it( 'should reset the freeform content fallback block name', () => { + const state = freeformFallbackBlockName( 'core/code', { + type: 'REMOVE_BLOCK_TYPES', + names: [ 'core/code' ], + } ); + + expect( state ).toBeNull(); + } ); +} ); + +describe( 'groupingBlockName', () => { + it( 'should return null as default state', () => { + expect( groupingBlockName( undefined, {} ) ).toBeNull(); + } ); + + it( 'should set the grouping block name', () => { + const state = groupingBlockName( null, { + type: 'SET_GROUPING_BLOCK_NAME', + name: 'core/group', + } ); + + expect( state ).toBe( 'core/group' ); + } ); + + it( 'should reset the group fallback block name', () => { + const state = groupingBlockName( 'core/group', { + type: 'REMOVE_BLOCK_TYPES', + names: [ 'core/group' ], + } ); + + expect( state ).toBeNull(); + } ); +} ); + +describe( 'unregisteredFallbackBlockName', () => { + it( 'should return null as default state', () => { + expect( unregisteredFallbackBlockName( undefined, {} ) ).toBeNull(); + } ); + + it( 'should set the unregistered fallback block name', () => { + const state = unregisteredFallbackBlockName( null, { + type: 'SET_UNREGISTERED_FALLBACK_BLOCK_NAME', + name: 'core/paragraph', + } ); + + expect( state ).toBe( 'core/paragraph' ); + } ); + + it( 'should reset the unregistered fallback block name', () => { + const state = unregisteredFallbackBlockName( 'core/code', { + type: 'REMOVE_BLOCK_TYPES', + names: [ 'core/code' ], + } ); + + expect( state ).toBeNull(); + } ); +} ); + +describe( 'categories', () => { + it( 'should return the default categories as default state', () => { + expect( categories( undefined, {} ) ).toEqual( DEFAULT_CATEGORIES ); + } ); + + it( 'should override categories', () => { + const original = deepFreeze( [ + { slug: 'chicken', title: 'Chicken' }, + ] ); + + const state = categories( original, { + type: 'SET_CATEGORIES', + categories: [ { slug: 'wings', title: 'Wings' } ], + } ); + + expect( state ).toEqual( [ + { slug: 'wings', title: 'Wings' }, + ] ); + } ); + + it( 'should add the category icon', () => { + const original = deepFreeze( [ { + slug: 'chicken', + title: 'Chicken', + } ] ); + + const state = categories( original, { + type: 'UPDATE_CATEGORY', + slug: 'chicken', + category: { + icon: 'new-icon', + }, + } ); + + expect( state ).toEqual( [ { + slug: 'chicken', + title: 'Chicken', + icon: 'new-icon', + } ] ); + } ); + + it( 'should update the category icon', () => { + const original = deepFreeze( [ { + slug: 'chicken', + title: 'Chicken', + icon: 'old-icon', + }, { + slug: 'wings', + title: 'Wings', + icon: 'old-icon', + } ] ); + + const state = categories( original, { + type: 'UPDATE_CATEGORY', + slug: 'chicken', + category: { + icon: 'new-icon', + }, + } ); + + expect( state ).toEqual( [ { + slug: 'chicken', + title: 'Chicken', + icon: 'new-icon', + }, { + slug: 'wings', + title: 'Wings', + icon: 'old-icon', + } ] ); + } ); + + it( 'should update multiple category properties', () => { + const original = deepFreeze( [ { + slug: 'chicken', + title: 'Chicken', + icon: 'old-icon', + }, { + slug: 'wings', + title: 'Wings', + icon: 'old-icon', + } ] ); + + const state = categories( original, { + type: 'UPDATE_CATEGORY', + slug: 'wings', + category: { + title: 'New Wings', + chicken: 'ribs', + }, + } ); + + expect( state ).toEqual( [ { + slug: 'chicken', + title: 'Chicken', + icon: 'old-icon', + }, { + slug: 'wings', + title: 'New Wings', + chicken: 'ribs', + icon: 'old-icon', + } ] ); + } ); +} ); diff --git a/gb-src/packages/blocks/src/store/test/selectors.js b/gb-src/packages/blocks/src/store/test/selectors.js new file mode 100644 index 0000000000000..05fc5a23040a8 --- /dev/null +++ b/gb-src/packages/blocks/src/store/test/selectors.js @@ -0,0 +1,213 @@ +/** + * Internal dependencies + */ +import { + getChildBlockNames, + isMatchingSearchTerm, + getGroupingBlockName, +} from '../selectors'; + +describe( 'selectors', () => { + describe( 'getChildBlockNames', () => { + it( 'should return an empty array if state is empty', () => { + const state = {}; + + expect( getChildBlockNames( state, 'parent1' ) ).toHaveLength( 0 ); + } ); + + it( 'should return an empty array if no children exist', () => { + const state = { + blockTypes: [ + { + name: 'child1', + parent: [ 'parent1' ], + }, + { + name: 'child2', + parent: [ 'parent2' ], + }, + { + name: 'parent3', + }, + ], + }; + + expect( getChildBlockNames( state, 'parent3' ) ).toHaveLength( 0 ); + } ); + + it( 'should return an empty array if the parent block is not found', () => { + const state = { + blockTypes: [ + { + name: 'child1', + parent: [ 'parent1' ], + }, + { + name: 'parent1', + }, + ], + }; + + expect( getChildBlockNames( state, 'parent3' ) ).toHaveLength( 0 ); + } ); + + it( 'should return an array with the child block names', () => { + const state = { + blockTypes: [ + { + name: 'child1', + parent: [ 'parent1' ], + }, + { + name: 'child2', + parent: [ 'parent2' ], + }, + { + name: 'child3', + parent: [ 'parent1' ], + }, + { + name: 'child4', + }, + { + name: 'parent1', + }, + { + name: 'parent2', + }, + ], + }; + + expect( getChildBlockNames( state, 'parent1' ) ).toEqual( [ 'child1', 'child3' ] ); + } ); + + it( 'should return an array with the child block names even if only one child exists', () => { + const state = { + blockTypes: [ + { + name: 'child1', + parent: [ 'parent1' ], + }, + { + name: 'child2', + parent: [ 'parent2' ], + }, + { + name: 'child4', + }, + { + name: 'parent1', + }, + { + name: 'parent2', + }, + ], + }; + + expect( getChildBlockNames( state, 'parent1' ) ).toEqual( [ 'child1' ] ); + } ); + + it( 'should return an array with the child block names even if children have multiple parents', () => { + const state = { + blockTypes: [ + { + name: 'child1', + parent: [ 'parent1' ], + }, + { + name: 'child2', + parent: [ 'parent1', 'parent2' ], + }, + { + name: 'child3', + parent: [ 'parent1' ], + }, + { + name: 'child4', + }, + { + name: 'parent1', + }, + { + name: 'parent2', + }, + ], + }; + + expect( getChildBlockNames( state, 'parent1' ) ).toEqual( [ 'child1', 'child2', 'child3' ] ); + expect( getChildBlockNames( state, 'parent2' ) ).toEqual( [ 'child2' ] ); + } ); + } ); + + describe( 'isMatchingSearchTerm', () => { + const name = 'core/paragraph'; + const blockType = { + title: 'Paragraph', + category: 'common', + keywords: [ 'text' ], + }; + + const state = { + blockTypes: { + [ name ]: blockType, + }, + }; + + describe.each( [ + [ 'name', name ], + [ 'block type', blockType ], + ] )( 'by %s', ( label, nameOrType ) => { + it( 'should return false if not match', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'Quote' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true if match by title', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'Paragraph' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return true if match ignoring case', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'PARAGRAPH' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return true if match ignoring diacritics', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'PÁRAGRAPH' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return true if match ignoring whitespace', () => { + const result = isMatchingSearchTerm( state, nameOrType, ' PARAGRAPH ' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return true if match using the keywords', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'TEXT' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return true if match using the categories', () => { + const result = isMatchingSearchTerm( state, nameOrType, 'COMMON' ); + + expect( result ).toBe( true ); + } ); + } ); + } ); + + describe( 'getGroupingBlockName', () => { + it( 'returns the grouping block name from state', () => { + const state = { + groupingBlockName: 'core/group', + }; + + expect( getGroupingBlockName( state ) ).toEqual( 'core/group' ); + } ); + } ); +} ); diff --git a/gb-src/packages/browserslist-config/.npmrc b/gb-src/packages/browserslist-config/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/browserslist-config/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/browserslist-config/CHANGELOG.md b/gb-src/packages/browserslist-config/CHANGELOG.md new file mode 100644 index 0000000000000..f0805564bdcd2 --- /dev/null +++ b/gb-src/packages/browserslist-config/CHANGELOG.md @@ -0,0 +1,61 @@ +## 2.2.0 (2018-07-12) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 2.1.4 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) + +## 2.1.3 (2018-05-18) + +### Polish + +- Docs update of the Browserslist example repo URL ([#98](https://github.com/WordPress/packages/pull/98)) + +## 2.1.2 (2018-02-28) + +### Polish + +- Removed `package-lock.json` file, lockfiles for apps, not packages ([#88](https://github.com/WordPress/packages/pull/88)) + +## 2.1.1 (2018-02-21) + +### Polish + +- Added "Code is Poetry" footer to README.md ([#82](https://github.com/WordPress/packages/pull/82)) + +## 2.1.0 (2018-02-15) + +### New Feature + +- Updated Browserslist to v3.1.0 ([#81](https://github.com/WordPress/packages/pull/81)) + +## 2.0.0 + +### Breaking Change + +- Transfer to @wordpress npm organization. +- Published as @wordpress/browserslist-config. + +## 1.1.0 + +### New Feature + +- Add browserslist tests. +- Refactor per coding standards. + +## 1.0.1 + +### Polish + +- Update installation documentation. + +## 1.0.0 + +### New Feature + +- Initial release. diff --git a/gb-src/packages/browserslist-config/README.md b/gb-src/packages/browserslist-config/README.md new file mode 100644 index 0000000000000..995cd1a9d69a6 --- /dev/null +++ b/gb-src/packages/browserslist-config/README.md @@ -0,0 +1,31 @@ +# Browserslist Config + +[WordPress Browserslist](https://make.wordpress.org/core/handbook/best-practices/browser-support/) shareable config for [Browserslist](https://www.npmjs.com/package/browserslist). + +## Installation + +Install the module + +```shell +$ npm install browserslist @wordpress/browserslist-config --save-dev +``` + +## Usage + +Add this to your `package.json` file: + +```json +"browserslist": [ + "extends @wordpress/browserslist-config" +] +``` + +Alternatively, add this to `.browserslistrc` file: + +``` +extends @wordpress/browserslist-config +``` + +This package when imported returns an array of supported browsers, for more configuration examples including Autoprefixer, Babel, ESLint, PostCSS, and stylelint see the [Browserslist examples](https://github.com/ai/browserslist-example#browserslist-example) repo. + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/browserslist-config/index.js b/gb-src/packages/browserslist-config/index.js new file mode 100644 index 0000000000000..132223516f8be --- /dev/null +++ b/gb-src/packages/browserslist-config/index.js @@ -0,0 +1,13 @@ +// browserslist-config/index.js +module.exports = [ + '> 1%', + 'ie >= 11', + 'last 1 Android versions', + 'last 1 ChromeAndroid versions', + 'last 2 Chrome versions', + 'last 2 Firefox versions', + 'last 2 Safari versions', + 'last 2 iOS versions', + 'last 2 Edge versions', + 'last 2 Opera versions', +]; diff --git a/gb-src/packages/browserslist-config/package.json b/gb-src/packages/browserslist-config/package.json new file mode 100644 index 0000000000000..318a8f299899e --- /dev/null +++ b/gb-src/packages/browserslist-config/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/browserslist-config", + "version": "2.6.0", + "description": "WordPress Browserslist shared configuration.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "browserslist", + "browserslist-config" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/browserslist-config/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/browserslist-config" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "index.js" + ], + "main": "index.js", + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/browserslist-config/test/index.test.js b/gb-src/packages/browserslist-config/test/index.test.js new file mode 100644 index 0000000000000..9ec97a4dd8547 --- /dev/null +++ b/gb-src/packages/browserslist-config/test/index.test.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import browserslist from 'browserslist'; + +/** + * Internal dependencies + */ +import config from '../'; + +it( 'should export an array', () => { + expect( Array.isArray( config ) ).toBe( true ); +} ); + +it( 'should not contain invalid queries', () => { + const result = browserslist( [ 'extends @wordpress/browserslist-config' ] ); + + expect( result ).toBeTruthy(); +} ); diff --git a/gb-src/packages/components/.npmrc b/gb-src/packages/components/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/components/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/components/CHANGELOG.md b/gb-src/packages/components/CHANGELOG.md new file mode 100644 index 0000000000000..00cd959951484 --- /dev/null +++ b/gb-src/packages/components/CHANGELOG.md @@ -0,0 +1,238 @@ +## 8.2.0 (2019-08-29) + +### New Features + +- The bundled `re-resizable` dependency has been updated from requiring `5.0.1` to requiring `^6.0.0` ([#17011](https://github.com/WordPress/gutenberg/pull/17011)). + +## 8.1.0 (2019-08-05) + +### New Features + +- Added a new `popoverProps` prop to the `Dropdown` component which allows users of the `Dropdown` component to pass props directly to the `Popover` component. +- Added and documented `hideLabelFromVision` prop to `BaseControl` used by `SelectControl`, `TextControl`, and `TextareaControl`. +- Added a new `popoverProps` prop to the `DropdownMenu` component which allows to pass props directly to the nested `Popover` component. +- Added a new `toggleProps` prop to the `DropdownMenu` component which allows to pass props directly to the nested `IconButton` component. +- Added a new `menuProps` prop to the `DropdownMenu` component which allows to pass props directly to the nested `NavigableMenu` component. + +### Deprecations + +- `menuLabel` prop in `DropdownComponent` has been deprecated. Consider using `menuProps` object and its `aria-label` property instead. +- `position` prop in `DropdownComponent` has been deprecated. Consider using `popoverProps` object and its `position` property instead. + +### Bug Fixes + +- The `Button` component will no longer assign default styling (`is-default` class) when explicitly assigned as primary (the `isPrimary` prop). This should resolve potential conflicts affecting a combination of `isPrimary`, `isDefault`, and `isLarge` / `isSmall`, where the busy animation would appear with incorrect coloring. + +### Deprecations + +- The `Popover` component `onClickOutside` prop has been deprecated. Use `onFocusOutside` instead. + +### Internal + +- The `Dropdown` component has been refactored to focus changes using the `Popover` component's `onFocusOutside` prop. +- The `MenuItem` component will now always use an `IconButton`. This prevents a focus loss when clicking a menu item. +- Package no longer depends on external `react-click-outside` library. + +## 8.0.0 (2019-06-12) + +### New Feature + +- Add new `BlockQuotation` block to the primitives folder to support blockquote in a multiplatform way. [#15482](https://github.com/WordPress/gutenberg/pull/15482). +- `DropdownMenu` now supports passing a [render prop](https://reactjs.org/docs/render-props.html#using-props-other-than-render) as children for more advanced customization. + +### Internal + +- `MenuGroup` no longer uses `NavigableMenu` internally. It needs to be explicitly wrapped with `NavigableMenu` to bring back the same behavior. + +### Documentation + +- Added missing documentation for `DropdownMenu` props `menuLabel`, `position`, `className`. + +### Breaking Change + +- `ServerSideRender` is no longer part of components. It was extracted to an independent package `@wordpress/server-side-render`. + +### Bug Fix + +- Although `DateTimePicker` does not allow picking the seconds, passed the current seconds as the selected value for seconds when calling `onChange`. Now it passes zero. + +## 7.4.0 (2019-05-21) + +### New Feature + +- Added a new `HorizontalRule` component. +- Added a new `Snackbar` component. + +### Bug Fix + +- Fixed display of reset button when using RangeControl `allowReset` prop. +- Fixed minutes field of `DateTimePicker` missed '0' before single digit values. + +## 7.3.0 (2019-04-16) + +### New Features + +- Added a new `render` property to `FormFileUpload` component. Allowing users of the component to custom the UI for their needs. +- Added a new `BaseControl.VisualLabel` component. +- Added a new `preview` prop to the `Placeholder` component which allows to display a preview, for example a media preview when the Placeholder is used in media editing contexts. +- Added a new `anchorRect` prop to `Popover` which enables a developer to provide a custom `DOMRect` object at which to position the popover. + +### Improvements + +- Limit `Base Control Label` to the width of its content. + +### Bug fixes + +- Fix `instanceId` prop passed through to `Button` component via `MenuItems` producing React console error. Fixed by removing the unnecessary use of `withInstanceId` on the `MenuItems` component [#14599](https://github.com/WordPress/gutenberg/pull/14599) + +## 7.2.0 (2019-03-20) + +### Improvements + +- Make `RangeControl` validation rely on the `checkValidity` provided by the browsers instead of using our own validation. + +### Bug Fixes + +- Fix a problem that made `RangeControl` not work as expected with float values. + +## 7.1.0 (2019-03-06) + +### New Features + +- Added a new `Animate` component. + +### Improvements + +- `withFilters` has been optimized to avoid binding hook handlers for each mounted instance of the component, instead using a single centralized hook delegator. +- `withFilters` has been optimized to reuse a single shared component definition for all filtered instances of the component. +- Make `RangeControl` validate min and max properties. + +### Bug Fixes + +- Resolves a conflict where two instance of Slot would produce an inconsistent or duplicated rendering output. +- Allow years between 0 and 1970 in DateTime component. + +### New Feature + +- `Dropdown` now has a `focusOnMount` prop which is passed directly to the contained `Popover`. +- `DatePicker` has new prop `isInvalidDate` exposing react-dates' `isOutsideRange`. +- `DatePicker` allows `null` as accepted value for `currentDate` prop to signify no date selection. + +## 7.0.5 (2019-01-03) + +## 7.0.4 (2018-12-12) + +## 7.0.3 (2018-11-30) + +## 7.0.2 (2018-11-22) + +## 7.0.1 (2018-11-21) + +## 7.0.0 (2018-11-20) + +### Breaking Change + +- `Dropdown.refresh()` has been removed. The contained `Popover` is now automatically refreshed. + +## 6.0.2 (2018-11-15) + +## 6.0.1 (2018-11-12) + +### Bug Fixes + +- Avoid constantly recomputing the popover position. + +### Polish + +- Remove `<DateTimePicker />` obsolete `locale` prop (and pass-through to child components) and obsolete `is12Hour` prop pass through to `<DateTime />` [#11649](https://github.com/WordPress/gutenberg/pull/11649) + +## 6.0.0 (2018-11-12) + +### Breaking Change + +- The `PanelColor` component has been removed. + +## 5.1.1 (2018-11-09) + +## 5.1.0 (2018-11-09) + +### New Feature + +- Adjust a11y roles for MenuItem component, so that aria-checked is used properly, related change in Editor/Components/BlockNavigationList ([#11431](https://github.com/WordPress/gutenberg/issues/11431)). +- `Popover` components are now automatically refreshed every 0.5s in order to recalculate their size or position. + +### Deprecation + +- `Dropdown.refresh()` has been deprecated as the contained `Popover` is now automatically refreshed. + +## 5.0.2 (2018-11-03) + +### Polish + +- Forward `ref` in the `PanelBody` component. +- Tooltip are no longer removed when Button becomes disabled, it's left to the component rendering the Tooltip. +- Forward `ref` support in `TabbableContainer` and `NavigableMenu` components. + +## 5.0.1 (2018-10-30) + +## 5.0.0 (2018-10-29) + +### Breaking Change + +- `AccessibleSVG` component has been removed. Please use `SVG` instead. + +### New Feature + +- The `Notice` component accepts an array of action objects via the `actions` prop. Each member object should contain a `label` and either a `url` link string or `onClick` callback function. + +## 4.2.1 (2018-10-22) + +### Bug Fix + +- Fix importing `react-dates` stylesheet in production. + +## 4.2.0 (2018-10-19) + +### New Feature + +- Added a new `ColorPicker` component ([#10564](https://github.com/WordPress/gutenberg/pull/10564)). +- `MenuItem` now accepts an `info` prop for including an extended description. + +### Bug Fix + +- `IconButton` correctly respects a passed `aria-label` prop. + +### Deprecation + +- `PanelColor` has been deprecated in favor of `wp.editor.PanelColorSettings`. + +## 4.1.2 (2018-10-18) + +## 4.1.0 (2018-10-10) + +### New Feature + +- Added a new `ResizableBox` component. + +## 4.0.0 (2018-09-30) + +### Breaking Change + +- `Draggable` as a DOM node drag handler has been removed. Please, use `Draggable` as a wrap component for your DOM node drag handler. + +### Deprecation + +- Renamed `AccessibleSVG` component to `SVG`. + +## 3.0.0 (2018-09-05) + +### Breaking Change + +- `withAPIData` has been removed. Please use the Core Data module or `@wordpress/api-fetch` directly instead. +- `Draggable` as a DOM node drag handler has been deprecated. Please, use `Draggable` as a wrap component for your DOM node drag handler. +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. +- `withContext` has been removed. Please use `wp.element.createContext` instead. See: https://reactjs.org/docs/context.html. + +### New Feature + +- Added a new `AccessibleSVG` component. diff --git a/gb-src/packages/components/CONTRIBUTING.md b/gb-src/packages/components/CONTRIBUTING.md new file mode 100644 index 0000000000000..18ef8d72b238a --- /dev/null +++ b/gb-src/packages/components/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +Thank you for taking the time to contribute. + +The following is a set of guidelines for contributing to the `@wordpress/components` package to be considered in addition to the general ones described in our [Contributing Policy](/CONTRIBUTING.md). + +## Examples + +Each component needs to include an example in its README.md file to demonstrate the usage of the component. + +These examples can be consumed automatically from other projects in order to visualize them in their documentation. To ensure these examples are extractable, compilable and renderable, they should be structured in the following way: + +* It has to be included in a `jsx` code block. +* It has to work out-of-the-box. No additional code should be needed to have working the example. +* It has to define a React component called `My<ComponentName>` which renders the example (i.e.: `MyButton`). Examples for the Higher Order Components should define a `MyComponent<ComponentName>` component (i.e.: `MyComponentWithNotices`). diff --git a/gb-src/packages/components/README.md b/gb-src/packages/components/README.md new file mode 100644 index 0000000000000..fb82b7f526f86 --- /dev/null +++ b/gb-src/packages/components/README.md @@ -0,0 +1,32 @@ +# Component Reference + +This packages includes a library of generic WordPress components to be used for creating common UI elements shared between screens and features of the WordPress dashboard. + +## Installation + +Install the module + +```bash +npm install @wordpress/components --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +Within Gutenberg, these components can be accessed by importing from the `components` root directory: + +```jsx +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; + +export default function MyButton() { + return <Button>Click Me!</Button>; +} +``` + +Many components also include styles which will need to be output in order to appear correctly. Within WordPress, you can [add the `wp-components` stylesheet as a dependency of your plugin's stylesheet](https://developer.wordpress.org/reference/functions/wp_enqueue_style/#parameters). In other projects, you can link to the `build-style/style.css` file directly. + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/gb-src/packages/components/package.json b/gb-src/packages/components/package.json new file mode 100644 index 0000000000000..c76f5c504f56e --- /dev/null +++ b/gb-src/packages/components/package.json @@ -0,0 +1,52 @@ +{ + "name": "@wordpress/components", + "version": "8.3.4", + "description": "UI components for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "components" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/components/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/components" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/a11y": "file:../a11y", + "@wordpress/compose": "file:../compose", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", + "@wordpress/i18n": "file:../i18n", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", + "classnames": "^2.2.5", + "clipboard": "^2.0.1", + "dom-scroll-into-view": "^1.2.1", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "moment": "^2.22.1", + "mousetrap": "^1.6.2", + "re-resizable": "^6.0.0", + "react-dates": "^17.1.1", + "react-spring": "^8.0.20", + "rememo": "^3.0.0", + "tinycolor2": "^1.4.1", + "uuid": "^3.3.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/components/src/CONTRIBUTING.md b/gb-src/packages/components/src/CONTRIBUTING.md new file mode 100644 index 0000000000000..8acfc7811cf81 --- /dev/null +++ b/gb-src/packages/components/src/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing components + +You can contribute by adding, modifying, or deprecating components, as well as helping with design, development, and documentation. + +## Does it belong in the component library? + +A component library should include components that are generic and flexible enough to work across a variety of products. It should include what’s shared across many products and omit what’s not. + +To determine if a component should be added, ask yourself: + +- Could this component be used by other products/plugins? +- Does the new component overlap (in functionality or visual design) with any existing components? +- How much effort will be required to make and maintain? +- Is there a clear purpose for the component? + +Here’s a flowchart that can help determine if a new component is necessary: + +[![New component flowchart](https://wordpress.org/gutenberg/files/2019/07/New_component_flowchart.png)](https://coggle.it/diagram/WtUSrld3uAYZHsn-/t/new-ui-component/992b38cbe685d897b4aec6d0dd93cc4b47c06e0d4484eeb0d7d9a47fb2c48d94) + +## First steps + +If you have a component you'd like added or changed, start by opening a GitHub issue. Include a detailed description in which you: + +- Explain the rationale +- Detail the intended behavior +- Clarify whether it’s a variation of an existing component, or a new asset +- Include mockups of any fidelity (optional) +- Include any inspirations from other products (optional) + +This issue will be used to discuss the proposed changes and track progress. Reviewers start by discussing the proposal to determine if it's appropriate for WordPress Components, or if there's overlap with an existing component. + +It’s encouraged to surface works-in-progress. If you’re not able to complete all of the parts yourself, someone in the community may be able to pick up where you left off. + +## Next steps + +Once the team has discussed and approved the change, it's time to start implementing it. + +1. **Provide a rationale**: Explain how your component will add value to the system and the greater product ecosystem. Be sure to include any user experience and interaction descriptions. +2. **Draft documentation**: New components need development, design, and accessibility guidelines. Additionally, if your change adds additional behavior or expands a component’s features, those changes will need to be fully documented as well. Read through existing component documentation for examples. Start with a rough draft, and reviewers will help polish documentation. +3. **Provide working code**: The component or enhancement must be built in React. See the [developer contribution guidelines](https://github.com/WordPress/gutenberg/blob/master/docs/contributors/develop.md). +4. **Create a design spec**: Create sizing and styling annotations for all aspects of the component. This spec should provide a developer with everything they need to create the design in code. [Figma automatically does this for you](https://help.figma.com/article/32-developer-handoff). +5. **Create a Figma component**: Any new components or changes to existing components will be mirrored in the [WordPress Components Figma library](https://www.figma.com/file/ZtN5xslEVYgzU7Dd5CxgGZwq/WordPress-Components?node-id=735%3A0), so we’ll need to update the Figma library and publish the changes. Please follow the [guidelines](https://www.figma.com/file/ZtN5xslEVYgzU7Dd5CxgGZwq/WordPress-Components?node-id=746%3A38) for contributing to the Figma libraries. + +Remember, it’s unlikely that all parts will be done by one person. Contribute where you can, and others will help. + +## Component refinement + +Before a component is published it will need to be fine-tuned: + +1. **Expand** the features of the component to a minimum. Agree on what features should be included. +2. **Reduce** scope and leave off features lacking consensus. +3. **Quality assurance**: each contribution must adhere to system standards. + +### Quality assurance + +To ensure quality, each component should be tested. The testing process should be done during the development of the component and before the component is published. + +- **Accessibility**: Has the design and implementation accounted for accessibility? Please use the [WordPress accessibility guidelines](https://make.wordpress.org/accessibility/handbook/best-practices/). You must use the "Needs Accessibility Feedback" label and get a review from the accessibility team. It's best to request a review early (at the documentation stage) in order to ensure the component is designed inclusively from the outset. +- **Visual quality**: Does the component apply visual style — color, typography, icons, space, borders, and more — using appropriate variables, and does it follow [visual guidelines](https://make.wordpress.org/design/handbook/design-guide/)? You must use the "Needs Design Feedback" label and get a review from the design team. +- **Documentation**: Ensure that the component has proper documentation for development, design, and accessibility. +- **Sufficient states & variations**: Does it cover all the necessary variations (primary, secondary, dense, etc.) and states (default, hover, active, disabled, loading, etc.), within the intended scope? +- **Functionality**: Do all behaviors function as expected? +- **Responsiveness**: Does it incorporate responsive behaviors as needed? Is the component designed from a mobile-first perspective? Do all touch interactions work as expected? +- **Content resilience**: Is each dynamic word or image element resilient to too much, too little, and no content at all, respectively? How long can labels be, and what happens when you run out of space? +- **Composability**: Does it fit well when placed next to or layered with other components to form a larger composition? +- **Browser support**: Has the component visual quality and accuracy been checked across Safari, Chrome, Firefox, IE, etc? Please adhere to our [browser support requirements](https://github.com/WordPress/gutenberg/blob/master/packages/browserslist-config/index.js). + +## Deprecation + +A component or prop may need deprecation if it's renamed or removed. + +At no point does the deprecated component/prop become unavailable. Instead, the commitment to support it stops. The following steps are needed to deprecate a component: + +1. Communicate intent via regular channels. + 1. Describe reasoning for deprecation. + 2. Decide on a timeline. +2. Ensure backwards compatibility to keep it available. +3. Clearly mark the component as deprecated in documentation and other channels. diff --git a/gb-src/packages/components/src/README.md b/gb-src/packages/components/src/README.md new file mode 100644 index 0000000000000..d1a71f0f59a16 --- /dev/null +++ b/gb-src/packages/components/src/README.md @@ -0,0 +1,21 @@ +Components +========== + +This directory includes a library of generic React components to be used for creating common UI elements shared between screens and features of the WordPress dashboard. + +## Usage + +Within Gutenberg, these components can be accessed by importing from the `components` root directory: + +```jsx +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; + +const MyButton = () => { + return <Button>Click Me!</Button>; +} +``` + +Many components also include styles which will need to be output in order to appear correctly. Within WordPress, you can [add the `wp-components` stylesheet as a dependency of your plugin's stylesheet](https://developer.wordpress.org/reference/functions/wp_enqueue_style/#parameters). In other projects, you can link to the `build-style/style.css` file directly. diff --git a/gb-src/packages/components/src/animate/README.md b/gb-src/packages/components/src/animate/README.md new file mode 100644 index 0000000000000..d334fec6c0741 --- /dev/null +++ b/gb-src/packages/components/src/animate/README.md @@ -0,0 +1,53 @@ +# Animate + +Simple interface to introduce animations to components. + +## Usage + +```jsx +import { Animate } from '@wordpress/components'; + +const MyAnimatedNotice = () => ( + <Animate todo="Add missing props"> + { ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Animation finished.</p> + </Notice> + ) } + </Animate> +); +``` + +## Props + +Name | Type | Default | Description +--- | --- | --- | --- +`type` | `string` | `undefined` | Type of the animation to use. +`options` | `object` | `{}` | Options of the chosen animation. +`children` | `function` | `undefined` | A callback receiving a list of props ( `className` ) to apply to the DOM element to animate. + +## Available Animation Types + +### appear + +This animation is meant for popover/modal content, such as menus appearing. It shows the height and width of the animated element scaling from 0 to full size, from its point of origin. + +#### Options + +Name | Type | Default | Description +--- | --- | --- | --- +`origin` | `string` | `top center` | Point of origin (`top`, `bottom`,` middle right`, `left`, `center`). + +### loading + +This animation is meant to be used to indicate that activity is happening in the background. It is an infinitely-looping fade from 50% to full opacity. This animation has no options, and should be removed as soon as its relevant operation is completed. + +### slide-in + +This animation is meant for sidebars and sliding menus. It shows the height and width of the animated element moving from a hidden position to its normal one. + +#### Options + +Name | Type | Default | Description +--- | --- | --- | --- +`origin` | `string` | `left` | Point of origin (`left`). diff --git a/gb-src/packages/components/src/animate/index.js b/gb-src/packages/components/src/animate/index.js new file mode 100644 index 0000000000000..ee6ca3298e006 --- /dev/null +++ b/gb-src/packages/components/src/animate/index.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +function Animate( { type, options = {}, children } ) { + if ( type === 'appear' ) { + const { origin = 'top' } = options; + const [ yAxis, xAxis = 'center' ] = origin.split( ' ' ); + + return children( { + className: classnames( + 'components-animate__appear', + { + [ 'is-from-' + xAxis ]: xAxis !== 'center', + [ 'is-from-' + yAxis ]: yAxis !== 'middle', + }, + ), + } ); + } + + if ( type === 'slide-in' ) { + const { origin = 'left' } = options; + + return children( { + className: classnames( + 'components-animate__slide-in', + 'is-from-' + origin, + ), + } ); + } + + if ( type === 'loading' ) { + return children( { + className: classnames( + 'components-animate__loading', + ), + } ); + } + + return children( {} ); +} + +export default Animate; diff --git a/gb-src/packages/components/src/animate/style.scss b/gb-src/packages/components/src/animate/style.scss new file mode 100644 index 0000000000000..b6932156b244a --- /dev/null +++ b/gb-src/packages/components/src/animate/style.scss @@ -0,0 +1,61 @@ +.components-animate__appear { + animation: components-animate__appear-animation 0.1s cubic-bezier(0, 0, 0.2, 1) 0s; + animation-fill-mode: forwards; + @include reduce-motion("animation"); + + &.is-from-top, + &.is-from-top.is-from-left { + transform-origin: top left; + } + &.is-from-top.is-from-right { + transform-origin: top right; + } + &.is-from-bottom, + &.is-from-bottom.is-from-left { + transform-origin: bottom left; + } + &.is-from-bottom.is-from-right { + transform-origin: bottom right; + } +} + +@keyframes components-animate__appear-animation { + from { + transform: translateY(-2em) scaleY(0) scaleX(0); + } + to { + transform: translateY(0%) scaleY(1) scaleX(1); + } +} + +.components-animate__slide-in { + animation: components-animate__slide-in-animation 0.1s cubic-bezier(0, 0, 0.2, 1); + animation-fill-mode: forwards; + @include reduce-motion("animation"); + + &.is-from-left { + transform: translateX(+100%); + } +} + +@keyframes components-animate__slide-in-animation { + 100% { + transform: translateX(0%); + } +} + +.components-animate__loading { + animation: components-animate__loading 1.6s ease-in-out infinite; +} + +@keyframes components-animate__loading { + 0% { + opacity: 0.5; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.5; + } +} diff --git a/gb-src/packages/components/src/autocomplete/README.md b/gb-src/packages/components/src/autocomplete/README.md new file mode 100644 index 0000000000000..745323b23771c --- /dev/null +++ b/gb-src/packages/components/src/autocomplete/README.md @@ -0,0 +1,155 @@ +# Autocomplete + +This component is used to provide autocompletion support for a child input component. + +## Autocompleters + +Autocompleters enable us to offer users options for completing text input. For example, Gutenberg includes a user autocompleter that provides a list of user names and completes a selection with a user mention like `@mary`. + +Each completer declares: + +* Its name. +* The text prefix that should trigger the display of completion options. +* Raw option data. +* How to render an option's label. +* An option's keywords, words that will be used to match an option with user input. +* What the completion of an option looks like, including whether it should be inserted in the text or used to replace the current block. + +In addition, a completer may optionally declare: + +* A class name to be applied to the completion menu. +* Whether it should apply to a specified text node. +* Whether the completer applies in a given context, defined via a Range before and a Range after the autocompletion trigger and query. + +### The Completer Interface + +#### name + +The name of the completer. Useful for identifying a specific completer to be overridden via extensibility hooks. + +- Type: `String` +- Required: Yes + +#### options + +The raw options for completion. May be an array, a function that returns an array, or a function that returns a promise for an array. + +Options may be of any type or shape. The completer declares how those options are rendered and what their completions should be when selected. + +- Type: `Array|Function` +- Required: Yes + +#### triggerPrefix + +The string prefix that should trigger the completer. For example, Gutenberg's block completer is triggered when the '/' character is entered. + +- Type: `String` +- Required: Yes + +#### getOptionLabel + +A function that returns the label for a given option. A label may be a string or a mixed array of strings, elements, and components. + +- Type: `Function` +- Required: Yes + +#### getOptionKeywords + +A function that returns the keywords for the specified option. + +- Type: `Function` +- Required: No + +#### isOptionDisabled + +A function that returns whether or not the specified option should be disabled. Disabled options cannot be selected. + +- Type: `Function` +- Required: No + +#### getOptionCompletion + +A function that takes an option and responds with how the option should be completed. By default, the result is a value to be inserted in the text. However, a completer may explicitly declare how a completion should be treated by returning an object with `action` and `value` properties. The `action` declares what should be done with the `value`. + +There are currently two supported actions: + +* "insert-at-caret" - Insert the `value` into the text (the default completion action). +* "replace" - Replace the current block with the block specified in the `value` property. + +#### allowContext + +A function that takes a Range before and a Range after the autocomplete trigger and query text and returns a boolean indicating whether the completer should be considered for that context. + +- Type: `Function` +- Required: No + +#### className + +A class name to apply to the autocompletion popup menu. + +- Type: `String` +- Required: No + +#### isDebounced + +Whether to apply debouncing for the autocompleter. Set to true to enable debouncing. + +- Type: `Boolean` +- Required: No + +## Usage + +The following is a contrived completer for fresh fruit. + +```jsx +import { Autocomplete } from '@wordpress/components'; + +const MyAutocomplete = () => { + const autocompleters = [ + { + name: 'fruit', + // The prefix that triggers this completer + triggerPrefix: '~', + // The option data + options: [ + { visual: '🍎', name: 'Apple', id: 1 }, + { visual: '🍊', name: 'Orange', id: 2 }, + { visual: '🍇', name: 'Grapes', id: 3 }, + ], + // Returns a label for an option like "🍊 Orange" + getOptionLabel: option => ( + <span> + <span className="icon" >{ option.visual }</span>{ option.name } + </span> + ), + // Declares that options should be matched by their name + getOptionKeywords: option => [ option.name ], + // Declares that the Grapes option is disabled + isOptionDisabled: option => option.name === 'Grapes', + // Declares completions should be inserted as abbreviations + getOptionCompletion: option => ( + <abbr title={ option.name }>{ option.visual }</abbr> + ), + } + ]; + + return ( + <div> + <Autocomplete completers={ autocompleters }> + { ( { isExpanded, listBoxId, activeId } ) => ( + <div + contentEditable + suppressContentEditableWarning + aria-autocomplete="list" + aria-expanded={ isExpanded } + aria-owns={ listBoxId } + aria-activedescendant={ activeId } + > + </div> + ) } + </Autocomplete> + <p>Type ~ for triggering the autocomplete.</p> + </div> + ); +}; +``` diff --git a/gb-src/packages/components/src/autocomplete/index.js b/gb-src/packages/components/src/autocomplete/index.js new file mode 100644 index 0000000000000..283ccd92633b0 --- /dev/null +++ b/gb-src/packages/components/src/autocomplete/index.js @@ -0,0 +1,462 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { escapeRegExp, find, map, debounce, deburr } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, renderToString } from '@wordpress/element'; +import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { + create, + slice, + insert, + isCollapsed, + getTextContent, +} from '@wordpress/rich-text'; +import { getRectangleFromRange } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import Popover from '../popover'; +import withSpokenMessages from '../higher-order/with-spoken-messages'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Component)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} Completer + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +function filterOptions( search, options = [], maxResults = 10 ) { + const filtered = []; + for ( let i = 0; i < options.length; i++ ) { + const option = options[ i ]; + + // Merge label into keywords + let { keywords = [] } = option; + if ( 'string' === typeof option.label ) { + keywords = [ ...keywords, option.label ]; + } + + const isMatch = keywords.some( ( keyword ) => search.test( deburr( keyword ) ) ); + if ( ! isMatch ) { + continue; + } + + filtered.push( option ); + + // Abort early if max reached + if ( filtered.length === maxResults ) { + break; + } + } + + return filtered; +} + +function getCaretRect() { + const selection = window.getSelection(); + const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; + + if ( range ) { + return getRectangleFromRange( range ); + } +} + +export class Autocomplete extends Component { + static getInitialState() { + return { + search: /./, + selectedIndex: 0, + suppress: undefined, + open: undefined, + query: undefined, + filteredOptions: [], + }; + } + + constructor() { + super( ...arguments ); + + this.select = this.select.bind( this ); + this.reset = this.reset.bind( this ); + this.resetWhenSuppressed = this.resetWhenSuppressed.bind( this ); + this.handleKeyDown = this.handleKeyDown.bind( this ); + this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); + + this.state = this.constructor.getInitialState(); + } + + insertCompletion( replacement ) { + const { open, query } = this.state; + const { record, onChange } = this.props; + const end = record.start; + const start = end - open.triggerPrefix.length - query.length; + const toInsert = create( { html: renderToString( replacement ) } ); + + onChange( insert( record, toInsert, start, end ) ); + } + + select( option ) { + const { onReplace } = this.props; + const { open, query } = this.state; + const { getOptionCompletion } = open || {}; + + if ( option.isDisabled ) { + return; + } + + if ( getOptionCompletion ) { + const completion = getOptionCompletion( option.value, query ); + + const { action, value } = + ( undefined === completion.action || undefined === completion.value ) ? + { action: 'insert-at-caret', value: completion } : + completion; + + if ( 'replace' === action ) { + onReplace( [ value ] ); + } else if ( 'insert-at-caret' === action ) { + this.insertCompletion( value ); + } + } + + // Reset autocomplete state after insertion rather than before + // so insertion events don't cause the completion menu to redisplay. + this.reset(); + } + + reset() { + this.setState( this.constructor.getInitialState() ); + } + + resetWhenSuppressed() { + const { open, suppress } = this.state; + if ( open && suppress === open.idx ) { + this.reset(); + } + } + + announce( filteredOptions ) { + const { debouncedSpeak } = this.props; + if ( ! debouncedSpeak ) { + return; + } + if ( !! filteredOptions.length ) { + debouncedSpeak( sprintf( _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + filteredOptions.length + ), filteredOptions.length ), 'assertive' ); + } else { + debouncedSpeak( __( 'No results.' ), 'assertive' ); + } + } + + /** + * Load options for an autocompleter. + * + * @param {Completer} completer The autocompleter. + * @param {string} query The query, if any. + */ + loadOptions( completer, query ) { + const { options } = completer; + + /* + * We support both synchronous and asynchronous retrieval of completer options + * but internally treat all as async so we maintain a single, consistent code path. + * + * Because networks can be slow, and the internet is wonderfully unpredictable, + * we don't want two promises updating the state at once. This ensures that only + * the most recent promise will act on `optionsData`. This doesn't use the state + * because `setState` is batched, and so there's no guarantee that setting + * `activePromise` in the state would result in it actually being in `this.state` + * before the promise resolves and we check to see if this is the active promise or not. + */ + const promise = this.activePromise = Promise.resolve( + typeof options === 'function' ? options( query ) : options + ).then( ( optionsData ) => { + if ( promise !== this.activePromise ) { + // Another promise has become active since this one was asked to resolve, so do nothing, + // or else we might end triggering a race condition updating the state. + return; + } + const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( { + key: `${ completer.idx }-${ optionIndex }`, + value: optionData, + label: completer.getOptionLabel( optionData ), + keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], + isDisabled: completer.isOptionDisabled ? completer.isOptionDisabled( optionData ) : false, + } ) ); + + const filteredOptions = filterOptions( this.state.search, keyedOptions ); + const selectedIndex = filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; + this.setState( { + [ 'options_' + completer.idx ]: keyedOptions, + filteredOptions, + selectedIndex, + } ); + this.announce( filteredOptions ); + } ); + } + + handleKeyDown( event ) { + const { open, suppress, selectedIndex, filteredOptions } = this.state; + if ( ! open ) { + return; + } + if ( suppress === open.idx ) { + switch ( event.keyCode ) { + // cancel popup suppression on CTRL+SPACE + case SPACE: + const { ctrlKey, shiftKey, altKey, metaKey } = event; + if ( ctrlKey && ! ( shiftKey || altKey || metaKey ) ) { + this.setState( { suppress: undefined } ); + event.preventDefault(); + event.stopPropagation(); + } + break; + + // reset on cursor movement + case UP: + case DOWN: + case LEFT: + case RIGHT: + this.reset(); + } + return; + } + if ( filteredOptions.length === 0 ) { + return; + } + let nextSelectedIndex; + switch ( event.keyCode ) { + case UP: + nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case DOWN: + nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case ESCAPE: + this.setState( { suppress: open.idx } ); + break; + + case ENTER: + this.select( filteredOptions[ selectedIndex ] ); + break; + + case LEFT: + case RIGHT: + this.reset(); + return; + + default: + return; + } + + // Any handled keycode should prevent original behavior. This relies on + // the early return in the default case. + event.preventDefault(); + } + + componentDidUpdate( prevProps ) { + const { record, completers } = this.props; + const { record: prevRecord } = prevProps; + + if ( isCollapsed( record ) ) { + const text = deburr( getTextContent( slice( record, 0 ) ) ); + const prevText = deburr( getTextContent( slice( prevRecord, 0 ) ) ); + + if ( text !== prevText ) { + const textAfterSelection = getTextContent( slice( record, undefined, getTextContent( record ).length ) ); + const allCompleters = map( completers, ( completer, idx ) => ( { ...completer, idx } ) ); + const open = find( allCompleters, ( { triggerPrefix, allowContext } ) => { + const index = text.lastIndexOf( triggerPrefix ); + + if ( index === -1 ) { + return false; + } + + if ( allowContext && ! allowContext( text.slice( 0, index ), textAfterSelection ) ) { + return false; + } + + return /^\S*$/.test( text.slice( index + triggerPrefix.length ) ); + } ); + + if ( ! open ) { + this.reset(); + return; + } + + const safeTrigger = escapeRegExp( open.triggerPrefix ); + const match = text.match( new RegExp( `${ safeTrigger }(\\S*)$` ) ); + const query = match && match[ 1 ]; + const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; + + if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { + if ( open.isDebounced ) { + this.debouncedLoadOptions( open, query ); + } else { + this.loadOptions( open, query ); + } + } + // create a regular expression to filter the options + const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; + // filter the options we already have + const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; + // check if we should still suppress the popover + const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; + // update the state + if ( wasOpen || open ) { + this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query } ); + } + // announce the count of filtered options but only if they have loaded + if ( open && this.state[ 'options_' + open.idx ] ) { + this.announce( filteredOptions ); + } + } + } + } + + componentWillUnmount() { + this.debouncedLoadOptions.cancel(); + } + + render() { + const { children, instanceId, isSelected } = this.props; + const { open, suppress, selectedIndex, filteredOptions } = this.state; + const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; + const { className, idx } = open || {}; + const isExpanded = suppress !== idx && filteredOptions.length > 0; + const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; + const activeId = isExpanded ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` : null; + + return ( + <> + { children( { + isExpanded, + listBoxId, + activeId, + onKeyDown: this.handleKeyDown, + } ) } + { isExpanded && isSelected && ( + <Popover + focusOnMount={ false } + onClose={ this.reset } + position="top right" + className="components-autocomplete__popover" + getAnchorRect={ getCaretRect } + > + <div + id={ listBoxId } + role="listbox" + className="components-autocomplete__results" + > + { isExpanded && map( filteredOptions, ( option, index ) => ( + <Button + key={ option.key } + id={ `components-autocomplete-item-${ instanceId }-${ option.key }` } + role="option" + aria-selected={ index === selectedIndex } + disabled={ option.isDisabled } + className={ classnames( 'components-autocomplete__result', className, { + 'is-selected': index === selectedIndex, + } ) } + onClick={ () => this.select( option ) } + > + { option.label } + </Button> + ) ) } + </div> + </Popover> + ) } + </> + ); + } +} + +export default compose( [ + withSpokenMessages, + withInstanceId, +] )( Autocomplete ); diff --git a/gb-src/packages/components/src/autocomplete/style.scss b/gb-src/packages/components/src/autocomplete/style.scss new file mode 100644 index 0000000000000..49a602b678940 --- /dev/null +++ b/gb-src/packages/components/src/autocomplete/style.scss @@ -0,0 +1,38 @@ +.components-autocomplete__popover .components-popover__content { + min-width: 200px; +} + +.components-autocomplete__popover .components-autocomplete__results { + padding: 3px; + display: flex; + flex-direction: column; + align-items: stretch; + + &:empty { + display: none; + } +} + +.components-autocomplete__result.components-button { + margin-bottom: 0; + font-family: $default-font; + font-size: $default-font-size; + color: $dark-gray-500; + display: flex; + flex-direction: row; + flex-grow: 1; + flex-shrink: 0; + align-items: center; + padding: 6px 8px; + margin-left: -3px; + margin-right: -3px; + text-align: left; + + &.is-selected { + @include menu-style__focus; + } + + &:hover { + @include menu-style__hover; + } +} diff --git a/gb-src/packages/components/src/base-control/README.md b/gb-src/packages/components/src/base-control/README.md new file mode 100644 index 0000000000000..7b20ced34734d --- /dev/null +++ b/gb-src/packages/components/src/base-control/README.md @@ -0,0 +1,112 @@ +# BaseControl + +BaseControl component is used to generate labels and help text for components handling user inputs. + + +## Usage + +Render a BaseControl for a textarea input: +```jsx +import { BaseControl } from '@wordpress/components'; + +const MyBaseControl = () => ( + <BaseControl + id="textarea-1" + label="Text" + help="Enter some text" + > + <textarea + id="textarea-1" + /> + </BaseControl> +); +``` + +## Props + +The component accepts the following props: + +### id + +The id of the element to which labels and help text are being generated. That element should be passed as a child. + +- Type: `String` +- Required: Yes + + +### label + +If this property is added, a label will be generated using label property as the content. + +- Type: `String` +- Required: No + +### hideLabelFromVision + +If true, the label will only be visible to screen readers. + +- Type: `Boolean` +- Required: No + +### help + +If this property is added, a help text will be generated using help property as the content. + +- Type: `String|WPElement` +- Required: No + +### className + +The class that will be added with "components-base-control" to the classes of the wrapper div. +If no className is passed only components-base-control is used. + +- Type: `String` +- Required: No + +### children + +The content to be displayed within the BaseControl. + +- Type: `Element` +- Required: Yes + +## BaseControl.VisualLabel + +`BaseControl.VisualLabel` component is used to render a purely visual label inside a `BaseControl` component. +It should only be used in cases where the children being rendered inside BaseControl are already properly labeled, e.g., a button, but we want an additional visual label for that section equivalent to the labels BaseControl would otherwise use if the label prop was passed. + + +## Usage +```jsx +import { BaseControl } from '@wordpress/components'; + +const MyBaseControl = () => ( + <BaseControl + help="Pressing the Select an author buttom will open a modal that allows an advanced mechanism for author selection" + > + <BaseControl.VisualLabel> + Author + </BaseControl.VisualLabel> + <Button> + Select an author + </Button> + </BaseControl> +); +``` + +### Props + +#### className + +The class that will be added with `components-base-control__label` to the classes of the wrapper div. +If no className is passed only `components-base-control__label` is used. + +- Type: `String` +- Required: No + +#### children + +The content to be displayed within the `BaseControl.VisualLabel`. + +- Type: `Element` +- Required: Yes diff --git a/gb-src/packages/components/src/base-control/index.js b/gb-src/packages/components/src/base-control/index.js new file mode 100644 index 0000000000000..3abe25ee06282 --- /dev/null +++ b/gb-src/packages/components/src/base-control/index.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +function BaseControl( { id, label, hideLabelFromVision, help, className, children } ) { + return ( + <div className={ classnames( 'components-base-control', className ) }> + <div className="components-base-control__field"> + { label && id && <label + className={ classnames( 'components-base-control__label', { 'screen-reader-text': hideLabelFromVision } ) } + htmlFor={ id }>{ label } + </label> } + { label && ! id && <BaseControl.VisualLabel>{ label }</BaseControl.VisualLabel> } + { children } + </div> + { !! help && <p id={ id + '__help' } className="components-base-control__help">{ help }</p> } + </div> + ); +} + +BaseControl.VisualLabel = ( { className, children } ) => { + className = classnames( 'components-base-control__label', className ); + return ( + <span className={ className }> + { children } + </span> + ); +}; + +export default BaseControl; diff --git a/gb-src/packages/components/src/base-control/index.native.js b/gb-src/packages/components/src/base-control/index.native.js new file mode 100644 index 0000000000000..ddf4067054d83 --- /dev/null +++ b/gb-src/packages/components/src/base-control/index.native.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { Text, View } from 'react-native'; + +export default function BaseControl( { label, help, children } ) { + return ( + <View + accessible={ true } + accessibilityLabel={ label } + > + { label && <Text>{ label }</Text> } + { children } + { help && <Text>{ help }</Text> } + </View> + ); +} diff --git a/gb-src/packages/components/src/base-control/style.scss b/gb-src/packages/components/src/base-control/style.scss new file mode 100644 index 0000000000000..b8509f7743397 --- /dev/null +++ b/gb-src/packages/components/src/base-control/style.scss @@ -0,0 +1,26 @@ +.components-base-control { + font-family: $default-font; + font-size: $default-font-size; + + .components-base-control__field { + margin-bottom: $grid-size; + + .components-panel__row & { + margin-bottom: inherit; + } + } + + .components-base-control__label { + display: inline-block; + margin-bottom: $grid-size-small; + } + + .components-base-control__help { + margin-top: -$grid-size; + font-style: italic; + } +} + +.components-base-control + .components-base-control { + margin-bottom: $grid-size-large; +} diff --git a/gb-src/packages/components/src/button-group/README.md b/gb-src/packages/components/src/button-group/README.md new file mode 100644 index 0000000000000..700260e593eb8 --- /dev/null +++ b/gb-src/packages/components/src/button-group/README.md @@ -0,0 +1,63 @@ +# ButtonGroup + +ButtonGroup can be used to group any related buttons together. To emphasize related buttons, a group should share a common container. + +![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +#### Selected action + +![ButtonGroup selection](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1544127594329_ButtonGroup-Do.png) + +**Do** +Only one option in a button group can be selected and active at a time. Selecting one option deselects any other. + +### Best practices + +Button groups should: + +- **Be clearly and accurately labeled.** +- **Clearly communicate that clicking or tapping will trigger an action.** +- **Use established colors appropriately.** For example, only use red buttons for actions that are difficult or impossible to undo. +- **Have consistent locations in the interface.** + +### States + +![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png) + +**Active and available button groups** + +A button group’s state makes it clear which button is active. Hover and focus states express the available selection options for buttons in a button group. + +**Disabled button groups** + +Button groups that cannot be selected can either be given a disabled state, or be hidden. + +## Development guidelines + +### Usage + +```jsx +import { Button, ButtonGroup } from '@wordpress/components'; + +const MyButtonGroup = () => ( + <ButtonGroup> + <Button isPrimary>Button 1</Button> + <Button isPrimary>Button 2</Button> + </ButtonGroup> +); +``` + +## Related components + +- For individual buttons, use a `Button` component. +- To group `IconButtons` use a `Toolbar` component. diff --git a/gb-src/packages/components/src/button-group/index.js b/gb-src/packages/components/src/button-group/index.js new file mode 100644 index 0000000000000..b864ff7ccc701 --- /dev/null +++ b/gb-src/packages/components/src/button-group/index.js @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +function ButtonGroup( { className, ...props } ) { + const classes = classnames( 'components-button-group', className ); + + return ( + <div { ...props } className={ classes } role="group" /> + ); +} + +export default ButtonGroup; diff --git a/gb-src/packages/components/src/button-group/style.scss b/gb-src/packages/components/src/button-group/style.scss new file mode 100644 index 0000000000000..b256895f0a47a --- /dev/null +++ b/gb-src/packages/components/src/button-group/style.scss @@ -0,0 +1,33 @@ +.components-button-group { + display: inline-block; + + .components-button.is-button { + border-radius: 0; + display: inline-flex; + + & + .components-button.is-button { + margin-left: -1px; + } + + &:first-child { + border-radius: 3px 0 0 3px; + } + + &:last-child { + border-radius: 0 3px 3px 0; + } + + // The focused button should be elevated so the focus ring isn't cropped, + // as should the active button, because it has a different border color. + &:focus, + &.is-primary { + position: relative; + z-index: z-index(".components-button.is-button {:focus or .is-primary}"); + } + + // The active button should look pressed. + &.is-primary { + box-shadow: none; + } + } +} diff --git a/gb-src/packages/components/src/button/README.md b/gb-src/packages/components/src/button/README.md new file mode 100644 index 0000000000000..722740d443af5 --- /dev/null +++ b/gb-src/packages/components/src/button/README.md @@ -0,0 +1,145 @@ +# Button +Buttons let users take actions and make choices with a single click or tap. + +![Button components](https://make.wordpress.org/design/files/2019/03/button.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +Buttons tell users what actions they can take and give them a way to interact with the interface. You’ll find them throughout a UI, particularly in places like: + +- Modals +- Forms +- Toolbars + +### Best practices + +Buttons should: + +- **Be clearly and accurately labeled.** +- **Clearly communicate that clicking or tapping will trigger an action.** +- **Use established colors appropriately.** For example, only use red buttons for actions that are difficult or impossible to undo. +- **Prioritize the most important actions.** This helps users focus. Too many calls to action on one screen can be confusing, making users unsure what to do next. +- **Have consistent locations in the interface.** + +### Content guidelines + +Buttons should be clear and predictable—users should be able to anticipate what will happen when they click a button. Never deceive a user by mislabeling a button. + +Buttons text should lead with a strong verb that encourages action, and add a noun that clarifies what will actually change. The only exceptions are common actions like Save, Close, Cancel, or OK. Otherwise, use the {verb}+{noun} format to ensure that your button gives the user enough information. + +Button text should also be quickly scannable — avoid unnecessary words and articles like the, an, or a. + +### Types + +#### Link button + +Link buttons have low emphasis. They don’t stand out much on the page, so they’re used for less-important actions. What’s less important can vary based on context, but it’s usually a supplementary action to the main action we want someone to take. Link buttons are also useful when you don’t want to distract from the content. + +![Link button](https://make.wordpress.org/design/files/2019/03/link-button.png) + +#### Default button + +Default buttons have medium emphasis. The button appearance helps differentiate them from the page background, so they’re useful when you want more emphasis than a link button offers. + +![Default button](https://make.wordpress.org/design/files/2019/03/default-button.png) + +#### Primary button + +Primary buttons have high emphasis. Their color fill and shadow means they pop off the background. + +Since a high-emphasis button commands the most attention, a layout should contain a single primary button. This makes it clear that other buttons have less importance and helps users understand when an action requires their attention. + +![Primary button](https://make.wordpress.org/design/files/2019/03/primary-button.png) + +#### Text label + +All button types use text labels to describe the action that happens when a user taps a button. If there’s no text label, there should be an icon to signify what the button does (e.g. an IconButton component). + +![](https://make.wordpress.org/design/files/2019/03/do-link-button.png) + +**Do** +Use color to distinguish link button labels from other text. + +![](https://make.wordpress.org/design/files/2019/03/dont-wrap-button-text.png) + +**Don’t** +Don’t wrap button text. For maximum legibility, keep text labels on a single line. + +### Hierarchy + +![A layout with a single prominent button](https://make.wordpress.org/design/files/2019/03/button.png) + +A layout should contain a single prominently-located button. If multiple buttons are required, a single high-emphasis button can be joined by medium- and low-emphasis buttons mapped to less-important actions. When using multiple buttons, make sure the available state of one button doesn’t look like the disabled state of another. + +![A diagram showing high emphasis at the top, medium emphasis in the middle, and low emphasis at the bottom](https://make.wordpress.org/design/files/2019/03/button-hierarchy.png) + +A button’s level of emphasis helps determine its appearance, typography, and placement. + +#### Placement + +Use button types to express different emphasis levels for all the actions a user can perform. + +![A link, default, and primary button](https://make.wordpress.org/design/files/2019/03/button-layout.png) + +This screen layout uses: + +1. A primary button for high emphasis. +2. A default button for medium emphasis. +3. A link button for low emphasis. + +Placement best practices: + +- **Do**: When using multiple buttons in a row, show users which action is more important by placing it next to a button with a lower emphasis (e.g. a primary button next to a default button, or a default button next to a link button). +- **Don’t**: Don’t place two primary buttons next to one another — they compete for focus. Only use one primary button per view. +- **Don’t**: Don’t place a button below another button if there is space to place them side by side. +- **Caution**: Avoid using too many buttons on a single page. When designing pages in the app or website, think about the most important actions for users to take. Too many calls to action can cause confusion and make users unsure what to do next — we always want users to feel confident and capable. + +## Development guidelines + +### Usage + +Renders a button with default style. + +```jsx +import { Button } from "@wordpress/components"; + +const MyButton = () => ( + <Button isDefault> + Click me! + </Button> +); +``` + +### Props + +The presence of a `href` prop determines whether an `anchor` element is rendered instead of a `button`. + +Props not included in this set will be applied to the `a` or `button` element. + +Name | Type | Default | Description +--- | --- | --- | --- +`disabled` | `bool` | `false` | Whether the button is disabled. If `true`, this will force a `button` element to be rendered. +`href` | `string` | `undefined` | If provided, renders `a` instead of `button`. +`isDefault` | `bool` | `false` | Renders a default button style. +`isPrimary` | `bool` | `false` | Renders a primary button style. +`isTertiary` | `bool` | `false` | Renders a text-based button style. +`isDestructive` | `bool` | `false` | Renders a red text-based button style to indicate destructive behavior. +`isLarge` | `bool` | `false` | Increases the size of the button. +`isSmall` | `bool` | `false` | Decreases the size of the button. +`isToggled` | `bool` | `false` | Renders a toggled button style. +`isBusy` | `bool` | `false` | Indicates activity while a action is being performed. +`isLink` | `bool` | `false` | Renders a button with an anchor style. +`focus` | `bool` | `false` | Whether the button is focused. + +## Related components + +- To group buttons together, use the `ButtonGroup` component. +- To display an icon inside the button, use the `IconButton` component. diff --git a/gb-src/packages/components/src/button/index.js b/gb-src/packages/components/src/button/index.js new file mode 100644 index 0000000000000..da3441e7f2982 --- /dev/null +++ b/gb-src/packages/components/src/button/index.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createElement, forwardRef } from '@wordpress/element'; + +export function Button( props, ref ) { + const { + href, + target, + isPrimary, + isLarge, + isSmall, + isTertiary, + isToggled, + isBusy, + isDefault, + isLink, + isDestructive, + className, + disabled, + ...additionalProps + } = props; + + const classes = classnames( 'components-button', className, { + 'is-button': isDefault || isPrimary || isLarge || isSmall, + 'is-default': isDefault || ( ! isPrimary && ( isLarge || isSmall ) ), + 'is-primary': isPrimary, + 'is-large': isLarge, + 'is-small': isSmall, + 'is-tertiary': isTertiary, + 'is-toggled': isToggled, + 'is-busy': isBusy, + 'is-link': isLink, + 'is-destructive': isDestructive, + } ); + + const tag = href !== undefined && ! disabled ? 'a' : 'button'; + const tagProps = tag === 'a' ? { href, target } : { type: 'button', disabled }; + + return createElement( tag, { + ...tagProps, + ...additionalProps, + className: classes, + ref, + } ); +} + +export default forwardRef( Button ); diff --git a/gb-src/packages/components/src/button/index.native.js b/gb-src/packages/components/src/button/index.native.js new file mode 100644 index 0000000000000..7a90dd5313166 --- /dev/null +++ b/gb-src/packages/components/src/button/index.native.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { StyleSheet, TouchableOpacity, Text, View, Platform } from 'react-native'; + +const isAndroid = Platform.OS === 'android'; +const marginBottom = isAndroid ? -0.5 : 0; +const marginLeft = -3; + +const styles = StyleSheet.create( { + container: { + flex: 1, + padding: 3, + justifyContent: 'center', + alignItems: 'center', + }, + buttonInactive: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + aspectRatio: 1, + backgroundColor: 'white', + }, + buttonActive: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 6, + borderColor: '#2e4453', + aspectRatio: 1, + backgroundColor: '#2e4453', + }, + subscriptInactive: { + color: '#7b9ab1', + fontWeight: 'bold', + fontSize: 13, + alignSelf: 'flex-end', + marginLeft, + marginBottom, + }, + subscriptActive: { + color: 'white', + fontWeight: 'bold', + fontSize: 13, + alignSelf: 'flex-end', + marginLeft, + marginBottom, + }, +} ); + +export default function Button( props ) { + const { + children, + onClick, + disabled, + hint, + 'aria-disabled': ariaDisabled, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, + 'data-subscript': subscript, + } = props; + + const isDisabled = ariaDisabled || disabled; + const buttonViewStyle = { + opacity: isDisabled ? 0.2 : 1, + ...( ariaPressed ? styles.buttonActive : styles.buttonInactive ), + }; + + const states = []; + if ( ariaPressed ) { + states.push( 'selected' ); + } + + if ( isDisabled ) { + states.push( 'disabled' ); + } + + return ( + <TouchableOpacity + activeOpacity={ 0.7 } + accessible={ true } + accessibilityLabel={ ariaLabel } + accessibilityStates={ states } + accessibilityRole={ 'button' } + accessibilityHint={ hint } + onPress={ onClick } + style={ styles.container } + disabled={ isDisabled } + > + <View style={ buttonViewStyle }> + <View style={ { flexDirection: 'row' } }> + { children } + { subscript && ( <Text style={ ariaPressed ? styles.subscriptActive : styles.subscriptInactive }>{ subscript }</Text> ) } + </View> + </View> + </TouchableOpacity> + ); +} diff --git a/gb-src/packages/components/src/button/style.scss b/gb-src/packages/components/src/button/style.scss new file mode 100644 index 0000000000000..6d3f94a18a1c2 --- /dev/null +++ b/gb-src/packages/components/src/button/style.scss @@ -0,0 +1,247 @@ +.components-button { + display: inline-flex; + text-decoration: none; + font-size: $default-font-size; + margin: 0; + border: 0; + cursor: pointer; + -webkit-appearance: none; + background: none; + transition: box-shadow 0.1s linear; + @include reduce-motion("transition"); + + &.is-button { + padding: 0 10px; + line-height: 2; + height: 28px; + border-radius: 3px; + white-space: nowrap; + border-width: $border-width; + border-style: solid; + } + + // Default button style + &.is-default { + color: color(theme(button) shade(6%)); + border-color: color(theme(button) shade(6%)); + background: #f3f5f6; + + &:hover { + background: #f1f1f1; + border-color: color(theme(button) shade(25%)); + color: color(theme(button) shade(25%)); + text-decoration: none; + } + + &:focus:enabled { + background: #f3f5f6; + color: color(theme(button) shade(25%)); + border-color: color(theme(button) shade(5%)); + box-shadow: 0 0 0 1px color(theme(button) shade(5%)); + text-decoration: none; + + } + + &:active:enabled { + background: #f3f5f6; + color: color(theme(button) shade(5%)); + border-color: #7e8993; + box-shadow: none; + } + + &:disabled, + &[aria-disabled="true"] { + color: #a0a5aa; + border-color: #ddd; + background: #f7f7f7; + text-shadow: 0 1px 0 #fff; + transform: none; + opacity: 1; + } + } + + &.is-primary { + background: color(theme(button)); + border-color: color(theme(button)); + color: $white; + text-decoration: none; + text-shadow: none; + + &:hover, + &:focus:enabled { + background: color(theme(button) shade(10%)); + border-color: color(theme(button) shade(10%)); + color: $white; + } + + &:focus:enabled { + box-shadow: + 0 0 0 1px $white, + 0 0 0 3px color(theme(button)); + } + + &:active:enabled { + background: color(theme(button) shade(20%)); + border-color: color(theme(button) shade(20%)); + color: $white; + } + + &:disabled, + &:disabled:active:enabled, + &[aria-disabled="true"], + &[aria-disabled="true"]:enabled, // This catches a situation where a Button is aria-disabled, but not disabled. + &[aria-disabled="true"]:active:enabled { + color: color(theme(button) tint(40%)); + background: color(theme(button) tint(10%)); + border-color: color(theme(button) tint(10%)); + opacity: 1; + + // This specificity is needed to override alternate color schemes in WP-Admin. + // This can presumably be removed when https://core.trac.wordpress.org/ticket/34904 lands. + &.is-button, + &.is-button:hover, + &:active:enabled { + box-shadow: none; + text-shadow: none; + } + + &:focus:enabled { + box-shadow: + 0 0 0 1px $white, + 0 0 0 3px color(theme(button)); + } + } + + &.is-busy, + &.is-busy:disabled, + &.is-busy[aria-disabled="true"] { + color: $white; + background-size: 100px 100%; + // Disable reason: This function call looks nicer when each argument is on its own line. + /* stylelint-disable */ + background-image: linear-gradient( + -45deg, + theme(button) 28%, + color(theme(button) shade(20%)) 28%, + color(theme(button) shade(20%)) 72%, + theme(button) 72% + ); + /* stylelint-enable */ + border-color: color(theme(button)); + } + } + + /* Buttons that look like links, for a cross of good semantics with the visual */ + &.is-link { + margin: 0; + padding: 0; + box-shadow: none; + border: 0; + border-radius: 0; + background: none; + outline: none; + text-align: left; + /* Mimics the default link style in common.css */ + color: #0073aa; + text-decoration: underline; + transition-property: border, background, color; + transition-duration: 0.05s; + transition-timing-function: ease-in-out; + @include reduce-motion("transition"); + + &:hover, + &:active { + color: #00a0d2; + } + + &:focus { + color: #124964; + box-shadow: + 0 0 0 1px #5b9dd9, + 0 0 2px 1px rgba(30, 140, 190, 0.8); + } + } + + /* Link buttons that are red to indicate destructive behavior. */ + &.is-link.is-destructive { + color: $alert-red; + } + + &:active { + color: inherit; + } + + &:disabled, + &[aria-disabled="true"] { + cursor: default; + opacity: 0.3; + } + + &:focus:not(:disabled) { + @include button-style__focus-active; + } + + &.is-busy, + &.is-default.is-busy, + &.is-default.is-busy:disabled, + &.is-default.is-busy[aria-disabled="true"] { + animation: components-button__busy-animation 2500ms infinite linear; + background-size: 100px 100%; + background-image: repeating-linear-gradient(-45deg, $light-gray-500, $white 11px, $white 10px, $light-gray-500 20px); + opacity: 1; + } + + &.is-large { + height: 30px; + line-height: 28px; + padding: 0 12px 2px; + } + + &.is-small { + height: 24px; + line-height: 22px; + padding: 0 8px 1px; + font-size: 11px; + } + + // Buttons that are text-based. + &.is-tertiary { + color: theme(outlines); + + // Matches default button in hit area. See line 11. + padding: 0 10px; + line-height: 26px; + height: 28px; + + .dashicon { + display: inline-block; + flex: 0 0 auto; + } + + // Ensure that even SVG icons that don't include the .dashicon class are colored. + svg { + fill: currentColor; + outline: none; + } + + &:active:focus:enabled { + box-shadow: none; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + color: color(theme(outlines) shade(25%)); + } + } + + // Fixes a Safari+VoiceOver bug, where the screen reader text is announced not respecting the source order. + // See https://core.trac.wordpress.org/ticket/42006 and https://github.com/h5bp/html5-boilerplate/issues/1985 + .screen-reader-text { + height: auto; + } +} + +@keyframes components-button__busy-animation { + 0% { + background-position: 200px 0; + } +} diff --git a/gb-src/packages/components/src/button/test/index.js b/gb-src/packages/components/src/button/test/index.js new file mode 100644 index 0000000000000..05e3580bfacbe --- /dev/null +++ b/gb-src/packages/components/src/button/test/index.js @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import TestUtils from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ButtonWithForwardedRef, { Button } from '../'; + +describe( 'Button', () => { + describe( 'basic rendering', () => { + it( 'should render a button element with only one class', () => { + const button = shallow( <Button /> ); + expect( button.hasClass( 'components-button' ) ).toBe( true ); + expect( button.hasClass( 'is-large' ) ).toBe( false ); + expect( button.hasClass( 'is-primary' ) ).toBe( false ); + expect( button.hasClass( 'is-toggled' ) ).toBe( false ); + expect( button.prop( 'disabled' ) ).toBeUndefined(); + expect( button.prop( 'type' ) ).toBe( 'button' ); + expect( button.type() ).toBe( 'button' ); + } ); + + it( 'should render a button element with is-primary class', () => { + const button = shallow( <Button isPrimary /> ); + expect( button.hasClass( 'is-large' ) ).toBe( false ); + expect( button.hasClass( 'is-primary' ) ).toBe( true ); + expect( button.hasClass( 'is-button' ) ).toBe( true ); + } ); + + it( 'should render a button element with is-large class', () => { + const button = shallow( <Button isLarge /> ); + expect( button.hasClass( 'is-large' ) ).toBe( true ); + expect( button.hasClass( 'is-default' ) ).toBe( true ); + expect( button.hasClass( 'is-button' ) ).toBe( true ); + expect( button.hasClass( 'is-primary' ) ).toBe( false ); + } ); + + it( 'should render a button element without is-default if primary', () => { + const button = shallow( <Button isPrimary isLarge /> ); + expect( button.hasClass( 'is-default' ) ).toBe( false ); + } ); + + it( 'should render a button element with is-small class', () => { + const button = shallow( <Button isSmall /> ); + expect( button.hasClass( 'is-default' ) ).toBe( true ); + expect( button.hasClass( 'is-button' ) ).toBe( true ); + expect( button.hasClass( 'is-large' ) ).toBe( false ); + expect( button.hasClass( 'is-small' ) ).toBe( true ); + expect( button.hasClass( 'is-primary' ) ).toBe( false ); + } ); + + it( 'should render a button element with is-toggled without button class', () => { + const button = shallow( <Button isToggled /> ); + expect( button.hasClass( 'is-button' ) ).toBe( false ); + expect( button.hasClass( 'is-toggled' ) ).toBe( true ); + } ); + + it( 'should add a disabled prop to the button', () => { + const button = shallow( <Button disabled /> ); + expect( button.prop( 'disabled' ) ).toBe( true ); + } ); + + it( 'should not poss the prop target into the element', () => { + const button = shallow( <Button target="_blank" /> ); + expect( button.prop( 'target' ) ).toBeUndefined(); + } ); + + it( 'should render with an additional className', () => { + const button = shallow( <Button className="gutenberg" /> ); + + expect( button.hasClass( 'gutenberg' ) ).toBe( true ); + } ); + + it( 'should render and additional WordPress prop of value awesome', () => { + const button = shallow( <Button WordPress="awesome" /> ); + + expect( button.prop( 'WordPress' ) ).toBe( 'awesome' ); + } ); + } ); + + describe( 'with href property', () => { + it( 'should render a link instead of a button with href prop', () => { + const button = shallow( <Button href="https://wordpress.org/" /> ); + + expect( button.type() ).toBe( 'a' ); + expect( button.prop( 'href' ) ).toBe( 'https://wordpress.org/' ); + } ); + + it( 'should allow for the passing of the target prop when a link is created', () => { + const button = shallow( <Button href="https://wordpress.org/" target="_blank" /> ); + + expect( button.prop( 'target' ) ).toBe( '_blank' ); + } ); + + it( 'should become a button again when disabled is supplied', () => { + const button = shallow( <Button href="https://wordpress.org/" disabled /> ); + + expect( button.type() ).toBe( 'button' ); + } ); + } ); + + describe( 'ref forwarding', () => { + it( 'should enable access to DOM element', () => { + const ref = createRef(); + + TestUtils.renderIntoDocument( <ButtonWithForwardedRef ref={ ref } /> ); + expect( ref.current.type ).toBe( 'button' ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/checkbox-control/README.md b/gb-src/packages/components/src/checkbox-control/README.md new file mode 100644 index 0000000000000..43165e8ae1b59 --- /dev/null +++ b/gb-src/packages/components/src/checkbox-control/README.md @@ -0,0 +1,120 @@ +# CheckboxControl + +Checkboxes allow the user to select one or more items from a set. + +![](https://make.wordpress.org/design/files/2019/02/CheckboxControl.png) + +Selected and unselected checkboxes + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +#### When to use checkboxes + +Use checkboxes when you want users to: + +- Select one or multiple items from a list. +- Open a list containing sub-selections. + +![](https://make.wordpress.org/design/files/2019/02/select-from-list.png) + +**Do** +Use checkboxes when users can select multiple items from a list. They let users select more than one item. + +![](https://make.wordpress.org/design/files/2019/02/many-form-toggles.png) + +**Don’t** +Don’t use toggles when a list consists of multiple options. Use checkboxes — they take up less space. + +![](https://make.wordpress.org/design/files/2019/02/checkbox-sublist.gif) + +Checkboxes can be used to open a list containing sub-selections. + +#### Parent and child checkboxes + +Checkboxes can have a parent-child relationship, with secondary options nested under primary options. + +![](https://make.wordpress.org/design/files/2019/02/checkbox-parent.gif) + +When the parent checkbox is *checked*, all the child checkboxes are checked. When a parent checkbox is *unchecked*, all the child checkboxes are unchecked. + +![](https://make.wordpress.org/design/files/2019/02/mixed-checkbox.png) + +If only a few child checkboxes are checked, the parent checkbox becomes a mixed checkbox. + +## Development guidelines + +### Usage + +Render an is author checkbox: +```jsx +import { CheckboxControl } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyCheckboxControl = withState( { + isChecked: true, +} )( ( { isChecked, setState } ) => ( + <CheckboxControl + heading="User" + label="Is author" + help="Is the user a author or not?" + checked={ isChecked } + onChange={ ( isChecked ) => { setState( { isChecked } ) } } + /> +) ); +``` + +### Props + +The set of props accepted by the component will be specified below. +Props not included in this set will be applied to the input element. + +#### heading + +A heading for the input field, that appears above the checkbox. If the prop is not passed no heading will be rendered. + +- Type: `String` +- Required: No + +#### label + +A label for the input field, that appears at the side of the checkbox. +The prop will be rendered as content a label element. +If no prop is passed an empty label is rendered. + +- Type: `String` +- Required: No + +#### help + +If this property is added, a help text will be generated using help property as the content. + +- Type: `String|WPElement` +- Required: No + +#### checked + +If checked is true the checkbox will be checked. If checked is false the checkbox will be unchecked. +If no value is passed the checkbox will be unchecked. + +- Type: `Boolean` +- Required: No + +#### onChange + +A function that receives the checked state (boolean) as input. + +- Type: `function` +- Required: Yes + +## Related components + +- To select one option from a set, and you want to show all the available options at once, use the `RadioControl` component. +- To toggle a single setting on or off, use the `FormToggle` component. diff --git a/gb-src/packages/components/src/checkbox-control/index.js b/gb-src/packages/components/src/checkbox-control/index.js new file mode 100644 index 0000000000000..77f75a3306498 --- /dev/null +++ b/gb-src/packages/components/src/checkbox-control/index.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BaseControl from '../base-control'; +import Dashicon from '../dashicon'; + +function CheckboxControl( { label, className, heading, checked, help, instanceId, onChange, ...props } ) { + const id = `inspector-checkbox-control-${ instanceId }`; + const onChangeValue = ( event ) => onChange( event.target.checked ); + + return ( + <BaseControl label={ heading } id={ id } help={ help } className={ className }> + <span className="components-checkbox-control__input-container"> + <input + id={ id } + className="components-checkbox-control__input" + type="checkbox" + value="1" + onChange={ onChangeValue } + checked={ checked } + aria-describedby={ !! help ? id + '__help' : undefined } + { ...props } + /> + { checked ? <Dashicon icon="yes" className="components-checkbox-control__checked" role="presentation" /> : null } + </span> + <label className="components-checkbox-control__label" htmlFor={ id }> + { label } + </label> + </BaseControl> + ); +} + +export default withInstanceId( CheckboxControl ); diff --git a/gb-src/packages/components/src/checkbox-control/style.scss b/gb-src/packages/components/src/checkbox-control/style.scss new file mode 100644 index 0000000000000..9909530c373c0 --- /dev/null +++ b/gb-src/packages/components/src/checkbox-control/style.scss @@ -0,0 +1,81 @@ +$checkbox-input-size: 16px; +$checkbox-input-size-sm: 25px; // width + height for small viewports + +.components-checkbox-control__input[type="checkbox"] { + border: 1px solid #b4b9be; + background: #fff; + color: #555; + clear: none; + cursor: pointer; + display: inline-block; + line-height: 0; + margin: 0 4px 0 0; + outline: 0; + padding: 0 !important; + text-align: center; + vertical-align: top; + width: $checkbox-input-size-sm; + height: $checkbox-input-size-sm; + @include break-small() { + height: $checkbox-input-size; + width: $checkbox-input-size; + } + -webkit-appearance: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); + transition: 0.05s border-color ease-in-out; + + &:focus { + border-color: #5b9dd9; + box-shadow: 0 0 2px rgba(30, 140, 190, 0.8); + // Only visible in Windows High Contrast mode. + outline: 2px solid transparent; + } + + &:checked { + background: #11a0d2; + border-color: #11a0d2; + + // Hide default checkbox styles in IE. + &::-ms-check { + opacity: 0; + } + } + + &:focus:checked { + border: none; + } + + &:checked::before { + content: none; + } +} + +.components-checkbox-control__input-container { + position: relative; + display: inline-block; + margin-right: 12px; + vertical-align: middle; + width: $checkbox-input-size-sm; + height: $checkbox-input-size-sm; + @include break-small() { + width: $checkbox-input-size; + height: $checkbox-input-size; + } +} + +svg.dashicon.components-checkbox-control__checked { + fill: #fff; + cursor: pointer; + position: absolute; + left: -4px; + top: -2px; + width: 31px; + height: 31px; + @include break-small() { + width: 21px; + height: 21px; + left: -3px; + } + user-select: none; + pointer-events: none; +} diff --git a/gb-src/packages/components/src/clipboard-button/README.md b/gb-src/packages/components/src/clipboard-button/README.md new file mode 100644 index 0000000000000..164af3cef4927 --- /dev/null +++ b/gb-src/packages/components/src/clipboard-button/README.md @@ -0,0 +1,26 @@ +# ClipboardButton + +With a clipboard button, users copy text (or other elements) with a single click or tap. + +![Clipboard button component](https://wordpress.org/gutenberg/files/2019/07/clipboard-button-2-1.png) + + +## Usage + +```jsx +import { ClipboardButton } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyClipboardButton = withState( { + hasCopied: false, +} )( ( { hasCopied, setState } ) => ( + <ClipboardButton + isPrimary + text="Text to be copied." + onCopy={ () => setState( { hasCopied: true } ) } + onFinishCopy={ () => setState( { hasCopied: false } ) } + > + { hasCopied ? 'Copied!' : 'Copy Text' } + </ClipboardButton> +) ); +``` diff --git a/gb-src/packages/components/src/clipboard-button/index.js b/gb-src/packages/components/src/clipboard-button/index.js new file mode 100644 index 0000000000000..cd6e9a6472bff --- /dev/null +++ b/gb-src/packages/components/src/clipboard-button/index.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import Clipboard from 'clipboard'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; +import Button from '../button'; + +class ClipboardButton extends Component { + constructor() { + super( ...arguments ); + + this.bindContainer = this.bindContainer.bind( this ); + this.onCopy = this.onCopy.bind( this ); + this.getText = this.getText.bind( this ); + } + + componentDidMount() { + const { container, getText, onCopy } = this; + const button = container.firstChild; + + this.clipboard = new Clipboard( button, { + text: getText, + container, + } ); + + this.clipboard.on( 'success', onCopy ); + } + + componentWillUnmount() { + this.clipboard.destroy(); + delete this.clipboard; + clearTimeout( this.onCopyTimeout ); + } + + bindContainer( container ) { + this.container = container; + } + + onCopy( args ) { + // Clearing selection will move focus back to the triggering button, + // ensuring that it is not reset to the body, and further that it is + // kept within the rendered node. + args.clearSelection(); + + const { onCopy, onFinishCopy } = this.props; + if ( onCopy ) { + onCopy(); + // For convenience and consistency, ClipboardButton offers to call + // a secondary callback with delay. This is useful to reset + // consumers' state, e.g. to revert a label from "Copied" to + // "Copy". + if ( onFinishCopy ) { + clearTimeout( this.onCopyTimeout ); + this.onCopyTimeout = setTimeout( onFinishCopy, 4000 ); + } + } + } + + getText() { + let text = this.props.text; + if ( 'function' === typeof text ) { + text = text(); + } + + return text; + } + + render() { + // Disable reason: Exclude from spread props passed to Button + // eslint-disable-next-line no-unused-vars + const { className, children, onCopy, onFinishCopy, text, ...buttonProps } = this.props; + const { icon } = buttonProps; + const classes = classnames( 'components-clipboard-button', className ); + const ComponentToUse = icon ? IconButton : Button; + + // Workaround for inconsistent behavior in Safari, where <textarea> is not + // the document.activeElement at the moment when the copy event fires. + // This causes documentHasSelection() in the copy-handler component to + // mistakenly override the ClipboardButton, and copy a serialized string + // of the current block instead. + const focusOnCopyEventTarget = ( event ) => { + event.target.focus(); + }; + + return ( + <span ref={ this.bindContainer } onCopy={ focusOnCopyEventTarget }> + <ComponentToUse { ...buttonProps } className={ classes }> + { children } + </ComponentToUse> + </span> + ); + } +} + +export default ClipboardButton; diff --git a/gb-src/packages/components/src/color-indicator/README.md b/gb-src/packages/components/src/color-indicator/README.md new file mode 100644 index 0000000000000..185e4961af711 --- /dev/null +++ b/gb-src/packages/components/src/color-indicator/README.md @@ -0,0 +1,11 @@ +# ColorIndicator + +## Usage + +```jsx +import { ColorIndicator } from '@wordpress/components'; + +const MyColorIndicator = () => ( + <ColorIndicator colorValue="#f00" /> +); +``` diff --git a/gb-src/packages/components/src/color-indicator/index.js b/gb-src/packages/components/src/color-indicator/index.js new file mode 100644 index 0000000000000..c2ee76857f798 --- /dev/null +++ b/gb-src/packages/components/src/color-indicator/index.js @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +const ColorIndicator = ( { className, colorValue, ...props } ) => ( + <span + className={ classnames( 'component-color-indicator', className ) } + style={ { background: colorValue } } + { ...props } + /> +); + +export default ColorIndicator; diff --git a/gb-src/packages/components/src/color-indicator/style.scss b/gb-src/packages/components/src/color-indicator/style.scss new file mode 100644 index 0000000000000..3729291268a8e --- /dev/null +++ b/gb-src/packages/components/src/color-indicator/style.scss @@ -0,0 +1,11 @@ +.component-color-indicator { + width: 25px; + height: 16px; + margin-left: 0.8rem; + border: 1px solid #dadada; + display: inline-block; + + & + & { + margin-left: 0.5rem; + } +} diff --git a/gb-src/packages/components/src/color-indicator/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/color-indicator/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..3485db28d0919 --- /dev/null +++ b/gb-src/packages/components/src/color-indicator/test/__snapshots__/index.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColorIndicator matches the snapshot 1`] = ` +<span + aria-label="sample label" + className="component-color-indicator" + style={ + Object { + "background": "#fff", + } + } +/> +`; diff --git a/gb-src/packages/components/src/color-indicator/test/index.js b/gb-src/packages/components/src/color-indicator/test/index.js new file mode 100644 index 0000000000000..74cc582858b17 --- /dev/null +++ b/gb-src/packages/components/src/color-indicator/test/index.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import ColorIndicator from '../'; + +describe( 'ColorIndicator', () => { + it( 'matches the snapshot', () => { + const wrapper = shallow( <ColorIndicator aria-label="sample label" colorValue="#fff" /> ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/components/src/color-palette/README.md b/gb-src/packages/components/src/color-palette/README.md new file mode 100644 index 0000000000000..83fac83707aec --- /dev/null +++ b/gb-src/packages/components/src/color-palette/README.md @@ -0,0 +1,76 @@ +# ColorPalette + +## Props + +The component accepts the following props. + +{ colors, disableCustomColors = false, value, onChange, className, clearable = true } +### colors + +Array with the colors to be shown. + +- Type: `Array` +- Required: Yes + +### disableCustomColors + +Whether to allow custom color or not. + +- Type: `Boolean` +- Required: No +- Default: false + +### value + +currently active value + +- Type: `String` +- Required: No + +### onChange + +Callback called when a color is selected. + +- Type: `Function` +- Required: Yes + +### className + +classes to be applied to the container. + +- Type: `String` +- Required: No +- Default: `Select or Upload Media` + +### clearable + +Whether the palette should have a clearing button or not. + +- Type: `Boolean` +- Required: No +- Default: true + + +## Usage +```jsx +import { ColorPalette } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyColorPalette = withState( { + color: '#f00', +} )( ( { color, setState } ) => { + const colors = [ + { name: 'red', color: '#f00' }, + { name: 'white', color: '#fff' }, + { name: 'blue', color: '#00f' }, + ]; + + return ( + <ColorPalette + colors={ colors } + value={ color } + onChange={ ( color ) => setState( { color } ) } + /> + ) +} ); +``` diff --git a/gb-src/packages/components/src/color-palette/index.js b/gb-src/packages/components/src/color-palette/index.js new file mode 100644 index 0000000000000..a30ae002eaf21 --- /dev/null +++ b/gb-src/packages/components/src/color-palette/index.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import Dropdown from '../dropdown'; +import Tooltip from '../tooltip'; +import ColorPicker from '../color-picker'; +import Dashicon from '../dashicon'; + +export default function ColorPalette( { colors, disableCustomColors = false, value, onChange, className, clearable = true } ) { + function applyOrUnset( color ) { + return () => onChange( value === color ? undefined : color ); + } + const customColorPickerLabel = __( 'Custom color picker' ); + const classes = classnames( 'components-color-palette', className ); + return ( + <div className={ classes }> + { map( colors, ( { color, name } ) => { + const style = { color }; + const itemClasses = classnames( 'components-color-palette__item', { 'is-active': value === color } ); + + return ( + <div key={ color } className="components-color-palette__item-wrapper"> + <Tooltip + text={ name || + // translators: %s: color hex code e.g: "#f00". + sprintf( __( 'Color code: %s' ), color ) + }> + <button + type="button" + className={ itemClasses } + style={ style } + onClick={ applyOrUnset( color ) } + aria-label={ name ? + // translators: %s: The name of the color e.g: "vivid red". + sprintf( __( 'Color: %s' ), name ) : + // translators: %s: color hex code e.g: "#f00". + sprintf( __( 'Color code: %s' ), color ) } + aria-pressed={ value === color } + /> + </Tooltip> + { value === color && <Dashicon icon="saved" /> } + </div> + ); + } ) } + + <div className="components-color-palette__custom-clear-wrapper"> + { ! disableCustomColors && + <Dropdown + className="components-color-palette__custom-color" + contentClassName="components-color-palette__picker" + renderToggle={ ( { isOpen, onToggle } ) => ( + <Button + aria-expanded={ isOpen } + onClick={ onToggle } + aria-label={ customColorPickerLabel } + isLink + > + { __( 'Custom Color' ) } + </Button> + ) } + renderContent={ () => ( + <ColorPicker + color={ value } + onChangeComplete={ ( color ) => onChange( color.hex ) } + disableAlpha + /> + ) } + /> + } + { !! clearable && ( + <Button + className="components-color-palette__clear" + type="button" + onClick={ () => onChange( undefined ) } + isSmall + isDefault + > + { __( 'Clear' ) } + </Button> + ) } + </div> + </div> + ); +} diff --git a/gb-src/packages/components/src/color-palette/style.scss b/gb-src/packages/components/src/color-palette/style.scss new file mode 100644 index 0000000000000..567d23ecf1a26 --- /dev/null +++ b/gb-src/packages/components/src/color-palette/style.scss @@ -0,0 +1,96 @@ +$color-palette-circle-size: 28px; +$color-palette-circle-spacing: 14px; + +.components-color-palette { + margin-right: -14px; + width: calc(100% + 14px); + + .components-color-palette__custom-clear-wrapper { + width: calc(100% - 14px); + display: flex; + justify-content: flex-end; + } +} + +.components-color-palette__item-wrapper { + display: inline-block; + height: $color-palette-circle-size; + width: $color-palette-circle-size; + margin-right: $color-palette-circle-spacing; + margin-bottom: $color-palette-circle-spacing; + vertical-align: top; + transform: scale(1); + transition: 100ms transform ease; + @include reduce-motion("transition"); + &:hover { + transform: scale(1.2); + } + + // Ensure that the <div> that <Dropdown> wraps our toggle button with is full height + & > div { + height: 100%; + width: 100%; + } +} + +.components-color-palette__item { + display: inline-block; + vertical-align: top; + height: 100%; + width: 100%; + border: none; + border-radius: 50%; + background: transparent; + box-shadow: inset 0 0 0 ($color-palette-circle-size / 2); + transition: 100ms box-shadow ease; + @include reduce-motion("transition"); + cursor: pointer; + + &.is-active { + box-shadow: inset 0 0 0 4px; + position: relative; + z-index: 1; + + & + .dashicons-saved { + position: absolute; + left: 4px; + top: 4px; + } + } + + &::after { + content: ""; + position: absolute; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + border-radius: $radius-round; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2); + // Show a thin circular outline in Windows high contrast mode, otherwise the button is invisible. + border: 1px solid transparent; + } + + &:focus { + outline: none; + &::after { + content: ""; + border: #{ $border-width * 2 } solid $dark-gray-400; + width: 32px; + height: 32px; + position: absolute; + top: -2px; + left: -2px; + border-radius: $radius-round; + box-shadow: inset 0 0 0 2px $white; + } + } +} + +.components-color-palette__custom-color { + margin-right: $grid-size-large; + + .components-button { + line-height: 22px; + } +} diff --git a/gb-src/packages/components/src/color-palette/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/color-palette/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..bef88aa74e182 --- /dev/null +++ b/gb-src/packages/components/src/color-palette/test/__snapshots__/index.js.snap @@ -0,0 +1,286 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColorPalette Dropdown .renderContent should render dropdown content 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <Pure(WithInstanceId(Saturation)) + hsl={ + Object { + "a": 1, + "h": 0, + "l": 50, + "s": 100, + } + } + hsv={ + Object { + "a": 1, + "h": 0, + "s": 100, + "v": 100, + } + } + onChange={[Function]} + /> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(255, 0, 0)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <Pure(WithInstanceId(Hue)) + hsl={ + Object { + "a": 1, + "h": 0, + "l": 50, + "s": 100, + } + } + onChange={[Function]} + /> + </div> + </div> + <Inputs + disableAlpha={true} + hex="#ff0000" + hsl={ + Object { + "a": 1, + "h": 0, + "l": 50, + "s": 100, + } + } + onChange={[Function]} + rgb={ + Object { + "a": 1, + "b": 0, + "g": 0, + "r": 255, + } + } + /> + </div> +</div> +`; + +exports[`ColorPalette Dropdown .renderToggle should render dropdown content 1`] = ` +<button + aria-expanded={true} + aria-label="Custom color picker" + className="components-button is-link" + onClick={[MockFunction]} + type="button" +> + Custom Color +</button> +`; + +exports[`ColorPalette Dropdown should render it correctly 1`] = ` +<Dropdown + className="components-color-palette__custom-color" + contentClassName="components-color-palette__picker" + renderContent={[Function]} + renderToggle={[Function]} +/> +`; + +exports[`ColorPalette should allow disabling custom color picker 1`] = ` +<div + className="components-color-palette" +> + <div + className="components-color-palette__item-wrapper" + key="#f00" + > + <Tooltip + text="red" + > + <button + aria-label="Color: red" + aria-pressed={true} + className="components-color-palette__item is-active" + onClick={[Function]} + style={ + Object { + "color": "#f00", + } + } + type="button" + /> + </Tooltip> + <Dashicon + icon="saved" + /> + </div> + <div + className="components-color-palette__item-wrapper" + key="#fff" + > + <Tooltip + text="white" + > + <button + aria-label="Color: white" + aria-pressed={false} + className="components-color-palette__item" + onClick={[Function]} + style={ + Object { + "color": "#fff", + } + } + type="button" + /> + </Tooltip> + </div> + <div + className="components-color-palette__item-wrapper" + key="#00f" + > + <Tooltip + text="blue" + > + <button + aria-label="Color: blue" + aria-pressed={false} + className="components-color-palette__item" + onClick={[Function]} + style={ + Object { + "color": "#00f", + } + } + type="button" + /> + </Tooltip> + </div> + <div + className="components-color-palette__custom-clear-wrapper" + > + <ForwardRef(Button) + className="components-color-palette__clear" + isDefault={true} + isSmall={true} + onClick={[Function]} + type="button" + > + Clear + </ForwardRef(Button)> + </div> +</div> +`; + +exports[`ColorPalette should render a dynamic toolbar of colors 1`] = ` +<div + className="components-color-palette" +> + <div + className="components-color-palette__item-wrapper" + key="#f00" + > + <Tooltip + text="red" + > + <button + aria-label="Color: red" + aria-pressed={true} + className="components-color-palette__item is-active" + onClick={[Function]} + style={ + Object { + "color": "#f00", + } + } + type="button" + /> + </Tooltip> + <Dashicon + icon="saved" + /> + </div> + <div + className="components-color-palette__item-wrapper" + key="#fff" + > + <Tooltip + text="white" + > + <button + aria-label="Color: white" + aria-pressed={false} + className="components-color-palette__item" + onClick={[Function]} + style={ + Object { + "color": "#fff", + } + } + type="button" + /> + </Tooltip> + </div> + <div + className="components-color-palette__item-wrapper" + key="#00f" + > + <Tooltip + text="blue" + > + <button + aria-label="Color: blue" + aria-pressed={false} + className="components-color-palette__item" + onClick={[Function]} + style={ + Object { + "color": "#00f", + } + } + type="button" + /> + </Tooltip> + </div> + <div + className="components-color-palette__custom-clear-wrapper" + > + <Dropdown + className="components-color-palette__custom-color" + contentClassName="components-color-palette__picker" + renderContent={[Function]} + renderToggle={[Function]} + /> + <ForwardRef(Button) + className="components-color-palette__clear" + isDefault={true} + isSmall={true} + onClick={[Function]} + type="button" + > + Clear + </ForwardRef(Button)> + </div> +</div> +`; diff --git a/gb-src/packages/components/src/color-palette/test/index.js b/gb-src/packages/components/src/color-palette/test/index.js new file mode 100644 index 0000000000000..a515b9b769628 --- /dev/null +++ b/gb-src/packages/components/src/color-palette/test/index.js @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import ColorPalette from '../'; + +describe( 'ColorPalette', () => { + const colors = [ { name: 'red', color: '#f00' }, { name: 'white', color: '#fff' }, { name: 'blue', color: '#00f' } ]; + const currentColor = '#f00'; + const onChange = jest.fn(); + + const wrapper = shallow( <ColorPalette colors={ colors } value={ currentColor } onChange={ onChange } /> ); + const buttons = wrapper.find( '.components-color-palette__item-wrapper button' ); + + beforeEach( () => { + onChange.mockClear(); + } ); + + test( 'should render a dynamic toolbar of colors', () => { + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should render three color button options', () => { + expect( buttons ).toHaveLength( 3 ); + } ); + + test( 'should call onClick on an active button with undefined', () => { + const activeButton = buttons.findWhere( ( button ) => button.hasClass( 'is-active' ) ); + activeButton.simulate( 'click' ); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( undefined ); + } ); + + test( 'should call onClick on an inactive button', () => { + const inactiveButton = buttons.findWhere( ( button ) => ! button.hasClass( 'is-active' ) ).first(); + inactiveButton.simulate( 'click' ); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'should call onClick with undefined, when the clearButton onClick is triggered', () => { + const clearButton = wrapper.find( '.components-color-palette__clear' ); + + expect( clearButton ).toHaveLength( 1 ); + + clearButton.simulate( 'click' ); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( undefined ); + } ); + + test( 'should allow disabling custom color picker', () => { + expect( shallow( <ColorPalette colors={ colors } disableCustomColors={ true } value={ currentColor } onChange={ onChange } /> ) ).toMatchSnapshot(); + } ); + + describe( 'Dropdown', () => { + const dropdown = wrapper.find( 'Dropdown' ); + + test( 'should render it correctly', () => { + expect( dropdown ).toMatchSnapshot(); + } ); + + describe( '.renderToggle', () => { + const isOpen = true; + const onToggle = jest.fn(); + + const renderedToggleButton = shallow( dropdown.props().renderToggle( { isOpen, onToggle } ) ); + + test( 'should render dropdown content', () => { + expect( renderedToggleButton ).toMatchSnapshot(); + } ); + + test( 'should call onToggle on click.', () => { + renderedToggleButton.simulate( 'click' ); + + expect( onToggle ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( '.renderContent', () => { + const renderedContent = shallow( dropdown.props().renderContent() ); + + test( 'should render dropdown content', () => { + expect( renderedContent ).toMatchSnapshot(); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/color-picker/README.md b/gb-src/packages/components/src/color-picker/README.md new file mode 100644 index 0000000000000..d2b1307a00d7b --- /dev/null +++ b/gb-src/packages/components/src/color-picker/README.md @@ -0,0 +1,23 @@ +# ColorPicker + +Accessible color picker. + +_Parts of the source code were derived and modified from [react-color](https://github.com/casesandberg/react-color/), released under the MIT license._ + +## Usage +```jsx +import { ColorPicker } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyColorPicker = withState( { + color: '#f00', +} )( ( { color, setState } ) => { + return ( + <ColorPicker + color={ color } + onChangeComplete={ ( value ) => setState( value.hex ) } + disableAlpha + /> + ); +} ); +``` diff --git a/gb-src/packages/components/src/color-picker/alpha.js b/gb-src/packages/components/src/color-picker/alpha.js new file mode 100644 index 0000000000000..eb6e889e7ca47 --- /dev/null +++ b/gb-src/packages/components/src/color-picker/alpha.js @@ -0,0 +1,178 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { TAB } from '@wordpress/keycodes'; +import { pure } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { calculateAlphaChange } from './utils'; +import KeyboardShortcuts from '../keyboard-shortcuts'; + +export class Alpha extends Component { + constructor() { + super( ...arguments ); + + this.container = createRef(); + this.increase = this.increase.bind( this ); + this.decrease = this.decrease.bind( this ); + this.handleChange = this.handleChange.bind( this ); + this.handleMouseDown = this.handleMouseDown.bind( this ); + this.handleMouseUp = this.handleMouseUp.bind( this ); + } + + componentWillUnmount() { + this.unbindEventListeners(); + } + + increase( amount = 0.01 ) { + const { hsl, onChange = noop } = this.props; + amount = parseInt( amount * 100, 10 ); + const change = { + h: hsl.h, + s: hsl.s, + l: hsl.l, + a: ( parseInt( hsl.a * 100, 10 ) + amount ) / 100, + source: 'rgb', + }; + onChange( change ); + } + + decrease( amount = 0.01 ) { + const { hsl, onChange = noop } = this.props; + const intValue = parseInt( hsl.a * 100, 10 ) - parseInt( amount * 100, 10 ); + const change = { + h: hsl.h, + s: hsl.s, + l: hsl.l, + a: hsl.a <= amount ? 0 : intValue / 100, + source: 'rgb', + }; + onChange( change ); + } + + handleChange( e ) { + const { onChange = noop } = this.props; + const change = calculateAlphaChange( e, this.props, this.container.current ); + if ( change ) { + onChange( change, e ); + } + } + + handleMouseDown( e ) { + this.handleChange( e ); + window.addEventListener( 'mousemove', this.handleChange ); + window.addEventListener( 'mouseup', this.handleMouseUp ); + } + + handleMouseUp() { + this.unbindEventListeners(); + } + + preventKeyEvents( event ) { + if ( event.keyCode === TAB ) { + return; + } + event.preventDefault(); + } + + unbindEventListeners() { + window.removeEventListener( 'mousemove', this.handleChange ); + window.removeEventListener( 'mouseup', this.handleMouseUp ); + } + + render() { + const { rgb } = this.props; + const rgbString = `${ rgb.r },${ rgb.g },${ rgb.b }`; + const gradient = { + background: `linear-gradient(to right, rgba(${ rgbString }, 0) 0%, rgba(${ rgbString }, 1) 100%)`, + }; + const pointerLocation = { left: `${ rgb.a * 100 }%` }; + + const shortcuts = { + up: () => this.increase(), + right: () => this.increase(), + 'shift+up': () => this.increase( 0.1 ), + 'shift+right': () => this.increase( 0.1 ), + pageup: () => this.increase( 0.1 ), + end: () => this.increase( 1 ), + down: () => this.decrease(), + left: () => this.decrease(), + 'shift+down': () => this.decrease( 0.1 ), + 'shift+left': () => this.decrease( 0.1 ), + pagedown: () => this.decrease( 0.1 ), + home: () => this.decrease( 1 ), + }; + + return ( + <KeyboardShortcuts shortcuts={ shortcuts }> + <div className="components-color-picker__alpha"> + <div + className="components-color-picker__alpha-gradient" + style={ gradient } + /> + { /* eslint-disable jsx-a11y/no-static-element-interactions */ } + <div + className="components-color-picker__alpha-bar" + ref={ this.container } + onMouseDown={ this.handleMouseDown } + onTouchMove={ this.handleChange } + onTouchStart={ this.handleChange }> + <div + tabIndex="0" + role="slider" + aria-valuemax="1" + aria-valuemin="0" + aria-valuenow={ rgb.a } + aria-orientation="horizontal" + aria-label={ __( + 'Alpha value, from 0 (transparent) to 1 (fully opaque).' + ) } + className="components-color-picker__alpha-pointer" + style={ pointerLocation } + onKeyDown={ this.preventKeyEvents } + /> + </div> + { /* eslint-enable jsx-a11y/no-static-element-interactions */ } + </div> + </KeyboardShortcuts> + ); + } +} + +export default pure( Alpha ); diff --git a/gb-src/packages/components/src/color-picker/hue.js b/gb-src/packages/components/src/color-picker/hue.js new file mode 100644 index 0000000000000..214df7e725bdc --- /dev/null +++ b/gb-src/packages/components/src/color-picker/hue.js @@ -0,0 +1,177 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose, pure, withInstanceId } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { TAB } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { calculateHueChange } from './utils'; +import KeyboardShortcuts from '../keyboard-shortcuts'; + +export class Hue extends Component { + constructor() { + super( ...arguments ); + + this.container = createRef(); + this.increase = this.increase.bind( this ); + this.decrease = this.decrease.bind( this ); + this.handleChange = this.handleChange.bind( this ); + this.handleMouseDown = this.handleMouseDown.bind( this ); + this.handleMouseUp = this.handleMouseUp.bind( this ); + } + + componentWillUnmount() { + this.unbindEventListeners(); + } + + increase( amount = 1 ) { + const { hsl, onChange = noop } = this.props; + const change = { + h: hsl.h + amount >= 359 ? 359 : hsl.h + amount, + s: hsl.s, + l: hsl.l, + a: hsl.a, + source: 'rgb', + }; + onChange( change ); + } + + decrease( amount = 1 ) { + const { hsl, onChange = noop } = this.props; + const change = { + h: hsl.h <= amount ? 0 : hsl.h - amount, + s: hsl.s, + l: hsl.l, + a: hsl.a, + source: 'rgb', + }; + onChange( change ); + } + + handleChange( e ) { + const { onChange = noop } = this.props; + const change = calculateHueChange( e, this.props, this.container.current ); + if ( change ) { + onChange( change, e ); + } + } + + handleMouseDown( e ) { + this.handleChange( e ); + window.addEventListener( 'mousemove', this.handleChange ); + window.addEventListener( 'mouseup', this.handleMouseUp ); + } + + handleMouseUp() { + this.unbindEventListeners(); + } + + preventKeyEvents( event ) { + if ( event.keyCode === TAB ) { + return; + } + event.preventDefault(); + } + + unbindEventListeners() { + window.removeEventListener( 'mousemove', this.handleChange ); + window.removeEventListener( 'mouseup', this.handleMouseUp ); + } + + render() { + const { hsl = {}, instanceId } = this.props; + + const pointerLocation = { left: `${ ( hsl.h * 100 ) / 360 }%` }; + const shortcuts = { + up: () => this.increase(), + right: () => this.increase(), + 'shift+up': () => this.increase( 10 ), + 'shift+right': () => this.increase( 10 ), + pageup: () => this.increase( 10 ), + end: () => this.increase( 359 ), + down: () => this.decrease(), + left: () => this.decrease(), + 'shift+down': () => this.decrease( 10 ), + 'shift+left': () => this.decrease( 10 ), + pagedown: () => this.decrease( 10 ), + home: () => this.decrease( 359 ), + }; + + return ( + <KeyboardShortcuts shortcuts={ shortcuts }> + <div className="components-color-picker__hue"> + <div className="components-color-picker__hue-gradient" /> + { /* eslint-disable jsx-a11y/no-static-element-interactions */ } + <div + className="components-color-picker__hue-bar" + ref={ this.container } + onMouseDown={ this.handleMouseDown } + onTouchMove={ this.handleChange } + onTouchStart={ this.handleChange }> + <div + tabIndex="0" + role="slider" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={ hsl.h } + aria-orientation="horizontal" + aria-label={ __( 'Hue value in degrees, from 0 to 359.' ) } + aria-describedby={ `components-color-picker__hue-description-${ instanceId }` } + className="components-color-picker__hue-pointer" + style={ pointerLocation } + onKeyDown={ this.preventKeyEvents } + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id={ `components-color-picker__hue-description-${ instanceId }` } + > + { __( 'Move the arrow left or right to change hue.' ) } + </p> + </div> + { /* eslint-enable jsx-a11y/no-static-element-interactions */ } + </div> + </KeyboardShortcuts> + ); + } +} + +export default compose( + pure, + withInstanceId +)( Hue ); diff --git a/gb-src/packages/components/src/color-picker/index.js b/gb-src/packages/components/src/color-picker/index.js new file mode 100644 index 0000000000000..74c42f3cee8f8 --- /dev/null +++ b/gb-src/packages/components/src/color-picker/index.js @@ -0,0 +1,240 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * External dependencies + */ +import classnames from 'classnames'; +import { debounce, noop, partial } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Alpha from './alpha'; +import Hue from './hue'; +import Inputs from './inputs'; +import Saturation from './saturation'; +import { colorToState, simpleCheckForValidColor, isValidHex } from './utils'; + +const toLowerCase = ( value ) => String( value ).toLowerCase(); +const isValueEmpty = ( data ) => { + if ( data.source === 'hex' && ! data.hex ) { + return true; + } else if ( data.source === 'hsl' && ( ! data.h || ! data.s || ! data.l ) ) { + return true; + } else if ( data.source === 'rgb' && ( + ( ! data.r || ! data.g || ! data.b ) && + ( ! data.h || ! data.s || ! data.v || ! data.a ) && + ( ! data.h || ! data.s || ! data.l || ! data.a ) + ) ) { + return true; + } + return false; +}; +const isValidColor = ( colors ) => colors.hex ? + isValidHex( colors.hex ) : + simpleCheckForValidColor( colors ); + +/** + * Function that creates the new color object + * from old data and the new value. + * + * @param {Object} oldColors The old color object. + * @param {string} oldColors.hex + * @param {Object} oldColors.rgb + * @param {number} oldColors.rgb.r + * @param {number} oldColors.rgb.g + * @param {number} oldColors.rgb.b + * @param {number} oldColors.rgb.a + * @param {Object} oldColors.hsl + * @param {number} oldColors.hsl.h + * @param {number} oldColors.hsl.s + * @param {number} oldColors.hsl.l + * @param {number} oldColors.hsl.a + * @param {string} oldColors.draftHex Same format as oldColors.hex + * @param {Object} oldColors.draftRgb Same format as oldColors.rgb + * @param {Object} oldColors.draftHsl Same format as oldColors.hsl + * @param {Object} data Data containing the new value to update. + * @param {Object} data.source One of `hex`, `rgb`, `hsl`. + * @param {string|number} data.value Value to update. + * @param {string} data.valueKey Depends on `data.source` values: + * - when source = `rgb`, valuKey can be `r`, `g`, `b`, or `a`. + * - when source = `hsl`, valuKey can be `h`, `s`, `l`, or `a`. + * @return {Object} A new color object for a specific source. For example: + * { source: 'rgb', r: 1, g: 2, b:3, a:0 } + */ +const dataToColors = ( oldColors, { source, valueKey, value } ) => { + if ( source === 'hex' ) { + return { + source, + [ source ]: value, + }; + } + return { + source, + ...{ ...oldColors[ source ], ...{ [ valueKey ]: value } }, + }; +}; + +export default class ColorPicker extends Component { + constructor( { color = '0071a1' } ) { + super( ...arguments ); + const colors = colorToState( color ); + this.state = { + ...colors, + draftHex: toLowerCase( colors.hex ), + draftRgb: colors.rgb, + draftHsl: colors.hsl, + }; + this.commitValues = this.commitValues.bind( this ); + this.setDraftValues = this.setDraftValues.bind( this ); + this.resetDraftValues = this.resetDraftValues.bind( this ); + this.handleInputChange = this.handleInputChange.bind( this ); + } + + commitValues( data ) { + const { oldHue, onChangeComplete = noop } = this.props; + + if ( isValidColor( data ) ) { + const colors = colorToState( data, data.h || oldHue ); + this.setState( { + ...colors, + draftHex: toLowerCase( colors.hex ), + draftHsl: colors.hsl, + draftRgb: colors.rgb, + }, + debounce( partial( onChangeComplete, colors ), 100 ) + ); + } + } + + resetDraftValues() { + this.setState( { + draftHex: this.state.hex, + draftHsl: this.state.hsl, + draftRgb: this.state.rgb, + } ); + } + + setDraftValues( data ) { + switch ( data.source ) { + case 'hex': + this.setState( { draftHex: toLowerCase( data.hex ) } ); + break; + case 'rgb': + this.setState( { draftRgb: data } ); + break; + case 'hsl': + this.setState( { draftHsl: data } ); + break; + } + } + + handleInputChange( data ) { + switch ( data.state ) { + case 'reset': + this.resetDraftValues(); + break; + case 'commit': + const colors = dataToColors( this.state, data ); + if ( ! isValueEmpty( colors ) ) { + this.commitValues( colors ); + } + break; + case 'draft': + this.setDraftValues( dataToColors( this.state, data ) ); + break; + } + } + + render() { + const { className, disableAlpha } = this.props; + const { + color, + hsl, + hsv, + rgb, + draftHex, + draftHsl, + draftRgb, + } = this.state; + const classes = classnames( className, { + 'components-color-picker': true, + 'is-alpha-disabled': disableAlpha, + 'is-alpha-enabled': ! disableAlpha, + } ); + + return ( + <div className={ classes }> + <div className="components-color-picker__saturation"> + <Saturation + hsl={ hsl } + hsv={ hsv } + onChange={ this.commitValues } + /> + </div> + + <div className="components-color-picker__body"> + <div className="components-color-picker__controls"> + <div className="components-color-picker__swatch"> + <div + className="components-color-picker__active" + style={ { + backgroundColor: color && color.toRgbString(), + } } + /> + </div> + + <div className="components-color-picker__toggles"> + <Hue hsl={ hsl } onChange={ this.commitValues } /> + { disableAlpha ? null : ( + <Alpha + rgb={ rgb } + hsl={ hsl } + onChange={ this.commitValues } + /> + ) } + </div> + </div> + + <Inputs + rgb={ draftRgb } + hsl={ draftHsl } + hex={ draftHex } + onChange={ this.handleInputChange } + disableAlpha={ disableAlpha } + /> + </div> + </div> + ); + } +} diff --git a/gb-src/packages/components/src/color-picker/inputs.js b/gb-src/packages/components/src/color-picker/inputs.js new file mode 100644 index 0000000000000..39817d13edb74 --- /dev/null +++ b/gb-src/packages/components/src/color-picker/inputs.js @@ -0,0 +1,300 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { DOWN, ENTER, UP } from '@wordpress/keycodes'; +import { pure } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; +import TextControl from '../text-control'; +import { isValidHex } from './utils'; + +/* Wrapper for TextControl, only used to handle intermediate state while typing. */ +export class Input extends Component { + constructor() { + super( ...arguments ); + this.handleBlur = this.handleBlur.bind( this ); + this.handleChange = this.handleChange.bind( this ); + this.handleKeyDown = this.handleKeyDown.bind( this ); + } + + handleBlur() { + const { value, valueKey, onChange, source } = this.props; + onChange( { + source, + state: 'commit', + value, + valueKey, + } ); + } + + handleChange( value ) { + const { valueKey, onChange, source } = this.props; + if ( value.length > 4 && isValidHex( value ) ) { + onChange( { + source, + state: 'commit', + value, + valueKey, + } ); + } else { + onChange( { + source, + state: 'draft', + value, + valueKey, + } ); + } + } + + handleKeyDown( { keyCode } ) { + if ( keyCode !== ENTER && keyCode !== UP && keyCode !== DOWN ) { + return; + } + const { value, valueKey, onChange, source } = this.props; + onChange( { + source, + state: 'commit', + value, + valueKey, + } ); + } + + render() { + const { label, value, ...props } = this.props; + return ( + <TextControl + className="components-color-picker__inputs-field" + label={ label } + value={ value } + onChange={ ( newValue ) => this.handleChange( newValue ) } + onBlur={ this.handleBlur } + onKeyDown={ this.handleKeyDown } + { ...omit( props, [ 'onChange', 'valueKey', 'source' ] ) } + /> + ); + } +} + +const PureIconButton = pure( IconButton ); + +export class Inputs extends Component { + constructor( { hsl } ) { + super( ...arguments ); + + const view = hsl.a === 1 ? 'hex' : 'rgb'; + this.state = { view }; + + this.toggleViews = this.toggleViews.bind( this ); + this.resetDraftValues = this.resetDraftValues.bind( this ); + this.handleChange = this.handleChange.bind( this ); + this.normalizeValue = this.normalizeValue.bind( this ); + } + + static getDerivedStateFromProps( props, state ) { + if ( props.hsl.a !== 1 && state.view === 'hex' ) { + return { view: 'rgb' }; + } + return null; + } + + toggleViews() { + if ( this.state.view === 'hex' ) { + this.setState( { view: 'rgb' }, this.resetDraftValues ); + + speak( __( 'RGB mode active' ) ); + } else if ( this.state.view === 'rgb' ) { + this.setState( { view: 'hsl' }, this.resetDraftValues ); + + speak( __( 'Hue/saturation/lightness mode active' ) ); + } else if ( this.state.view === 'hsl' ) { + if ( this.props.hsl.a === 1 ) { + this.setState( { view: 'hex' }, this.resetDraftValues ); + + speak( __( 'Hex color mode active' ) ); + } else { + this.setState( { view: 'rgb' }, this.resetDraftValues ); + + speak( __( 'RGB mode active' ) ); + } + } + } + + resetDraftValues() { + return this.props.onChange( { + state: 'reset', + } ); + } + + normalizeValue( valueKey, value ) { + if ( valueKey !== 'a' ) { + return value; + } + + if ( value > 0 ) { + return 0; + } else if ( value > 1 ) { + return 1; + } + return Math.round( value * 100 ) / 100; + } + + handleChange( { source, state, value, valueKey } ) { + this.props.onChange( { + source, + state, + valueKey, + value: this.normalizeValue( valueKey, value ), + } ); + } + + renderFields() { + const { disableAlpha = false } = this.props; + if ( this.state.view === 'hex' ) { + return ( + <div className="components-color-picker__inputs-fields"> + <Input + source={ this.state.view } + label={ __( 'Color value in hexadecimal' ) } + valueKey="hex" + value={ this.props.hex } + onChange={ this.handleChange } + /> + </div> + ); + } else if ( this.state.view === 'rgb' ) { + return ( + <fieldset> + <legend className="screen-reader-text"> + { __( 'Color value in RGB' ) } + </legend> + <div className="components-color-picker__inputs-fields"> + <Input + source={ this.state.view } + label="r" + valueKey="r" + value={ this.props.rgb.r } + onChange={ this.handleChange } + type="number" + min="0" + max="255" + /> + <Input + source={ this.state.view } + label="g" + valueKey="g" + value={ this.props.rgb.g } + onChange={ this.handleChange } + type="number" + min="0" + max="255" + /> + <Input + source={ this.state.view } + label="b" + valueKey="b" + value={ this.props.rgb.b } + onChange={ this.handleChange } + type="number" + min="0" + max="255" + /> + { disableAlpha ? null : ( + <Input + source={ this.state.view } + label="a" + valueKey="a" + value={ this.props.rgb.a } + onChange={ this.handleChange } + type="number" + min="0" + max="1" + step="0.05" + /> + ) } + </div> + </fieldset> + ); + } else if ( this.state.view === 'hsl' ) { + return ( + <fieldset> + <legend className="screen-reader-text"> + { __( 'Color value in HSL' ) } + </legend> + <div className="components-color-picker__inputs-fields"> + <Input + source={ this.state.view } + label="h" + valueKey="h" + value={ this.props.hsl.h } + onChange={ this.handleChange } + type="number" + min="0" + max="359" + /> + <Input + source={ this.state.view } + label="s" + valueKey="s" + value={ this.props.hsl.s } + onChange={ this.handleChange } + type="number" + min="0" + max="100" + /> + <Input + source={ this.state.view } + label="l" + valueKey="l" + value={ this.props.hsl.l } + onChange={ this.handleChange } + type="number" + min="0" + max="100" + /> + { disableAlpha ? null : ( + <Input + source={ this.state.view } + label="a" + valueKey="a" + value={ this.props.hsl.a } + onChange={ this.handleChange } + type="number" + min="0" + max="1" + step="0.05" + /> + ) } + </div> + </fieldset> + ); + } + } + + render() { + return ( + <div className="components-color-picker__inputs-wrapper"> + { this.renderFields() } + <div className="components-color-picker__inputs-toggle"> + <PureIconButton + icon="arrow-down-alt2" + label={ __( 'Change color format' ) } + onClick={ this.toggleViews } + /> + </div> + </div> + ); + } +} + +export default Inputs; diff --git a/gb-src/packages/components/src/color-picker/saturation.js b/gb-src/packages/components/src/color-picker/saturation.js new file mode 100644 index 0000000000000..3bdd10bfcdfe2 --- /dev/null +++ b/gb-src/packages/components/src/color-picker/saturation.js @@ -0,0 +1,191 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * External dependencies + */ +import { clamp, noop, throttle } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { TAB } from '@wordpress/keycodes'; +import { compose, pure, withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { calculateSaturationChange } from './utils'; +import KeyboardShortcuts from '../keyboard-shortcuts'; + +export class Saturation extends Component { + constructor( props ) { + super( props ); + + this.throttle = throttle( ( fn, data, e ) => { + fn( data, e ); + }, 50 ); + + this.container = createRef(); + this.saturate = this.saturate.bind( this ); + this.brighten = this.brighten.bind( this ); + this.handleChange = this.handleChange.bind( this ); + this.handleMouseDown = this.handleMouseDown.bind( this ); + this.handleMouseUp = this.handleMouseUp.bind( this ); + } + + componentWillUnmount() { + this.throttle.cancel(); + this.unbindEventListeners(); + } + + saturate( amount = 0.01 ) { + const { hsv, onChange = noop } = this.props; + const intSaturation = clamp( + hsv.s + Math.round( amount * 100 ), + 0, + 100 + ); + const change = { + h: hsv.h, + s: intSaturation, + v: hsv.v, + a: hsv.a, + source: 'rgb', + }; + + onChange( change ); + } + + brighten( amount = 0.01 ) { + const { hsv, onChange = noop } = this.props; + const intValue = clamp( + hsv.v + Math.round( amount * 100 ), + 0, + 100 + ); + const change = { + h: hsv.h, + s: hsv.s, + v: intValue, + a: hsv.a, + source: 'rgb', + }; + + onChange( change ); + } + + handleChange( e ) { + const { onChange = noop } = this.props; + const change = calculateSaturationChange( e, this.props, this.container.current ); + this.throttle( onChange, change, e ); + } + + handleMouseDown( e ) { + this.handleChange( e ); + window.addEventListener( 'mousemove', this.handleChange ); + window.addEventListener( 'mouseup', this.handleMouseUp ); + } + + handleMouseUp() { + this.unbindEventListeners(); + } + + preventKeyEvents( event ) { + if ( event.keyCode === TAB ) { + return; + } + event.preventDefault(); + } + + unbindEventListeners() { + window.removeEventListener( 'mousemove', this.handleChange ); + window.removeEventListener( 'mouseup', this.handleMouseUp ); + } + + render() { + const { hsv, hsl, instanceId } = this.props; + const pointerLocation = { + top: `${ -( hsv.v ) + 100 }%`, + left: `${ hsv.s }%`, + }; + const shortcuts = { + up: () => this.brighten(), + 'shift+up': () => this.brighten( 0.1 ), + pageup: () => this.brighten( 1 ), + down: () => this.brighten( -0.01 ), + 'shift+down': () => this.brighten( -0.1 ), + pagedown: () => this.brighten( -1 ), + right: () => this.saturate(), + 'shift+right': () => this.saturate( 0.1 ), + end: () => this.saturate( 1 ), + left: () => this.saturate( -0.01 ), + 'shift+left': () => this.saturate( -0.1 ), + home: () => this.saturate( -1 ), + }; + + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + return ( + <KeyboardShortcuts shortcuts={ shortcuts }> + <div + style={ { background: `hsl(${ hsl.h },100%, 50%)` } } + className="components-color-picker__saturation-color" + ref={ this.container } + onMouseDown={ this.handleMouseDown } + onTouchMove={ this.handleChange } + onTouchStart={ this.handleChange } + role="application" + > + <div className="components-color-picker__saturation-white" /> + <div className="components-color-picker__saturation-black" /> + <button + aria-label={ __( 'Choose a shade' ) } + aria-describedby={ `color-picker-saturation-${ instanceId }` } + className="components-color-picker__saturation-pointer" + style={ pointerLocation } + onKeyDown={ this.preventKeyEvents } + /> + <div + className="screen-reader-text" + id={ `color-picker-saturation-${ instanceId }` }> + { __( + 'Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation.' + ) } + </div> + </div> + </KeyboardShortcuts> + ); + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + } +} + +export default compose( + pure, + withInstanceId +)( Saturation ); diff --git a/gb-src/packages/components/src/color-picker/style.scss b/gb-src/packages/components/src/color-picker/style.scss new file mode 100644 index 0000000000000..3736be0d5f9d4 --- /dev/null +++ b/gb-src/packages/components/src/color-picker/style.scss @@ -0,0 +1,219 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +.components-color-picker { + width: 100%; + overflow: hidden; +} +.components-color-picker__saturation { + width: 100%; + padding-bottom: 55%; + position: relative; +} +.components-color-picker__body { + padding: $grid-size-large $grid-size-large #{ $grid-size-small * 3 }; +} +.components-color-picker__controls { + display: flex; +} +.components-color-picker__saturation-pointer, +.components-color-picker__hue-pointer, +.components-color-picker__alpha-pointer { + padding: 0; + position: absolute; + cursor: pointer; + box-shadow: none; + border: none; +} + +/* CURRENT COLOR COMPONENT */ +.components-color-picker__swatch { + margin-right: 8px; + width: 32px; + height: 32px; + border-radius: 50%; + position: relative; + overflow: hidden; + background-image: + linear-gradient(45deg, #ddd 25%, transparent 25%), + linear-gradient(-45deg, #ddd 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ddd 75%), + linear-gradient(-45deg, transparent 75%, #ddd 75%); + background-size: 10px 10px; + background-position: 0 0, 0 5px, 5px -5px, -5px 0; + + .is-alpha-disabled & { + width: 12px; + height: 12px; + margin-top: 0; + } +} +.components-color-picker__active { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + z-index: 2; +} + +/* SATURATION COMPONENT */ +.components-color-picker__saturation-color, +.components-color-picker__saturation-white, +.components-color-picker__saturation-black { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} +.components-color-picker__saturation-color { + overflow: hidden; +} +.components-color-picker__saturation-white { + /*rtl:ignore*/ + background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); +} +.components-color-picker__saturation-black { + background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)); +} +.components-color-picker__saturation-pointer { + width: 8px; + height: 8px; + box-shadow: + 0 0 0 1.5px #fff, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3), + 0 0 1px 2px rgba(0, 0, 0, 0.4); + border-radius: 50%; + background-color: transparent; + transform: translate(-4px, -4px); +} + +/* HUE & ALPHA BARS */ +.components-color-picker__toggles { + flex: 1; +} +.components-color-picker__alpha { + background-image: + linear-gradient(45deg, #ddd 25%, transparent 25%), + linear-gradient(-45deg, #ddd 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ddd 75%), + linear-gradient(-45deg, transparent 75%, #ddd 75%); + background-size: 10px 10px; + background-position: 0 0, 0 5px, 5px -5px, -5px 0; +} +.components-color-picker__hue-gradient, +.components-color-picker__alpha-gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} +.components-color-picker__hue, +.components-color-picker__alpha { + height: 12px; + position: relative; +} +.is-alpha-enabled .components-color-picker__hue { + margin-bottom: 8px; +} +.components-color-picker__hue-bar, +.components-color-picker__alpha-bar { + position: relative; + margin: 0 3px; + height: 100%; + padding: 0 2px; +} +.components-color-picker__hue-gradient { + /*rtl:ignore*/ + background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); +} +.components-color-picker__hue-pointer, +.components-color-picker__alpha-pointer { + /*rtl:ignore*/ + left: 0; + width: 14px; + height: 14px; + border-radius: 50%; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37); + background: #fff; + transform: translate(-7px, -1px); +} + +.components-color-picker__hue-pointer, +.components-color-picker__saturation-pointer { + transition: box-shadow 0.1s linear; + @include reduce-motion("transition"); +} + +.components-color-picker__saturation-pointer:focus { + box-shadow: + 0 0 0 2px #fff, + 0 0 0 4px $blue-medium-500, + 0 0 5px 0 $blue-medium-500, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3), + 0 0 1px 2px rgba(0, 0, 0, 0.4); +} + +.components-color-picker__hue-pointer:focus, +.components-color-picker__alpha-pointer:focus { + border-color: $blue-medium-500; + box-shadow: + 0 0 0 2px $blue-medium-500, + 0 0 3px 0 $blue-medium-500; + outline: 2px solid transparent; + outline-offset: -2px; +} + + +/* INPUTS COMPONENT */ +.components-color-picker__inputs-wrapper { + margin: 0 -4px; + padding-top: 16px; + display: flex; + align-items: flex-end; + + fieldset { + flex: 1; + } + + .components-color-picker__inputs-fields .components-text-control__input[type="number"] { + padding: 2px; + } +} +.components-color-picker__inputs-fields { + display: flex; + /*rtl:ignore*/ + direction: ltr; + + .components-base-control__field { + margin: 0 4px; + } +} diff --git a/gb-src/packages/components/src/color-picker/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/color-picker/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..5d61a24e5ac9e --- /dev/null +++ b/gb-src/packages/components/src/color-picker/test/__snapshots__/index.js.snap @@ -0,0 +1,1045 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColorPicker should commit changes to all views on blur 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(210,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-2" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "17%", + "top": "20%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-2" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(170, 187, 204)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-2" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={210} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "58.333333333333336%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-2" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-2" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-2" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#aabbcc" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; + +exports[`ColorPicker should commit changes to all views on keyDown = DOWN 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(210,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-4" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "17%", + "top": "20%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-4" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(170, 187, 204)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-4" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={210} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "58.333333333333336%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-4" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-4" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-4" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#aabbcc" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; + +exports[`ColorPicker should commit changes to all views on keyDown = ENTER 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(210,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-5" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "17%", + "top": "20%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-5" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(170, 187, 204)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-5" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={210} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "58.333333333333336%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-5" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-5" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-5" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#aabbcc" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; + +exports[`ColorPicker should commit changes to all views on keyDown = UP 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(210,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-3" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "17%", + "top": "20%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-3" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(170, 187, 204)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-3" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={210} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "58.333333333333336%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-3" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-3" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-3" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#aabbcc" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; + +exports[`ColorPicker should only update input view for draft changes 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(0,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-1" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "0%", + "top": "0%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-1" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(255, 255, 255)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-1" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={0} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "0%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-1" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-1" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-1" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#abc" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; + +exports[`ColorPicker should render color picker 1`] = ` +<div + className="components-color-picker is-alpha-disabled" +> + <div + className="components-color-picker__saturation" + > + <div> + <div + className="components-color-picker__saturation-color" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + role="application" + style={ + Object { + "background": "hsl(0,100%, 50%)", + } + } + > + <div + className="components-color-picker__saturation-white" + /> + <div + className="components-color-picker__saturation-black" + /> + <button + aria-describedby="color-picker-saturation-0" + aria-label="Choose a shade" + className="components-color-picker__saturation-pointer" + onKeyDown={[Function]} + style={ + Object { + "left": "0%", + "top": "0%", + } + } + /> + <div + className="screen-reader-text" + id="color-picker-saturation-0" + > + Use your arrow keys to change the base color. Move up to lighten the color, down to darken, left to decrease saturation, and right to increase saturation. + </div> + </div> + </div> + </div> + <div + className="components-color-picker__body" + > + <div + className="components-color-picker__controls" + > + <div + className="components-color-picker__swatch" + > + <div + className="components-color-picker__active" + style={ + Object { + "backgroundColor": "rgb(255, 255, 255)", + } + } + /> + </div> + <div + className="components-color-picker__toggles" + > + <div> + <div + className="components-color-picker__hue" + > + <div + className="components-color-picker__hue-gradient" + /> + <div + className="components-color-picker__hue-bar" + onMouseDown={[Function]} + onTouchMove={[Function]} + onTouchStart={[Function]} + > + <div + aria-describedby="components-color-picker__hue-description-0" + aria-label="Hue value in degrees, from 0 to 359." + aria-orientation="horizontal" + aria-valuemax="1" + aria-valuemin="359" + aria-valuenow={0} + className="components-color-picker__hue-pointer" + onKeyDown={[Function]} + role="slider" + style={ + Object { + "left": "0%", + } + } + tabIndex="0" + /> + <p + className="components-color-picker__hue-description screen-reader-text" + id="components-color-picker__hue-description-0" + > + Move the arrow left or right to change hue. + </p> + </div> + </div> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-wrapper" + > + <div + className="components-color-picker__inputs-fields" + > + <div + className="components-base-control components-color-picker__inputs-field" + > + <div + className="components-base-control__field" + > + <label + className="components-base-control__label" + htmlFor="inspector-text-control-0" + > + Color value in hexadecimal + </label> + <input + className="components-text-control__input" + id="inspector-text-control-0" + onBlur={[Function]} + onChange={[Function]} + onKeyDown={[Function]} + type="text" + value="#ffffff" + /> + </div> + </div> + </div> + <div + className="components-color-picker__inputs-toggle" + > + <button + aria-label="Change color format" + className="components-button components-icon-button" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + type="button" + > + <svg + aria-hidden="true" + className="dashicon dashicons-arrow-down-alt2" + focusable="false" + height={20} + role="img" + viewBox="0 0 20 20" + width={20} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 6l5 5 5-5 2 1-7 7-7-7z" + /> + </svg> + </button> + </div> + </div> + </div> +</div> +`; diff --git a/gb-src/packages/components/src/color-picker/test/index.js b/gb-src/packages/components/src/color-picker/test/index.js new file mode 100644 index 0000000000000..57e512bd65b8e --- /dev/null +++ b/gb-src/packages/components/src/color-picker/test/index.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { DOWN, ENTER, UP } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import ColorPicker from '../'; + +describe( 'ColorPicker', () => { + test( 'should render color picker', () => { + const color = '#FFF'; + + const renderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + + expect( renderer.toJSON() ).toMatchSnapshot(); + } ); + + test( 'should only update input view for draft changes', () => { + const color = '#FFF'; + const testRenderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + testRenderer.root.findByType( 'input' ).props.onChange( { target: { value: '#ABC' } } ); + expect( testRenderer.toJSON() ).toMatchSnapshot(); + } ); + + test( 'should commit changes to all views on blur', () => { + const color = '#FFF'; + const testRenderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + testRenderer.root.findByType( 'input' ).props.onChange( { target: { value: '#ABC' } } ); + testRenderer.root.findByType( 'input' ).props.onBlur(); + expect( testRenderer.toJSON() ).toMatchSnapshot(); + } ); + + test( 'should commit changes to all views on keyDown = UP', () => { + const color = '#FFF'; + const testRenderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + testRenderer.root.findByType( 'input' ).props.onChange( { target: { value: '#ABC' } } ); + testRenderer.root.findByType( 'input' ).props.onKeyDown( { keyCode: UP } ); + expect( testRenderer.toJSON() ).toMatchSnapshot(); + } ); + + test( 'should commit changes to all views on keyDown = DOWN', () => { + const color = '#FFF'; + const testRenderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + testRenderer.root.findByType( 'input' ).props.onChange( { target: { value: '#ABC' } } ); + testRenderer.root.findByType( 'input' ).props.onKeyDown( { keyCode: DOWN } ); + expect( testRenderer.toJSON() ).toMatchSnapshot(); + } ); + + test( 'should commit changes to all views on keyDown = ENTER', () => { + const color = '#FFF'; + const testRenderer = TestRenderer.create( + <ColorPicker + color={ color } + onChangeComplete={ () => {} } + disableAlpha + /> + ); + testRenderer.root.findByType( 'input' ).props.onChange( { target: { value: '#ABC' } } ); + testRenderer.root.findByType( 'input' ).props.onKeyDown( { keyCode: ENTER } ); + expect( testRenderer.toJSON() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/components/src/color-picker/test/input.js b/gb-src/packages/components/src/color-picker/test/input.js new file mode 100644 index 0000000000000..8e4bbd8f78d3c --- /dev/null +++ b/gb-src/packages/components/src/color-picker/test/input.js @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { DOWN, ENTER, SPACE, UP } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { Input } from '../inputs'; + +describe( 'Input ', () => { + describe( 'calls onChange prop with commit state', () => { + test( 'onKeyDown = ENTER', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onKeyDown( { keyCode: ENTER } ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'commit', + value: '#fff', + valueKey: 'hex', + } ); + } ); + + test( 'onKeyDown = UP', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onKeyDown( { keyCode: UP } ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'commit', + value: '#fff', + valueKey: 'hex', + } ); + } ); + + test( 'onKeyDown = DOWN', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onKeyDown( { keyCode: DOWN } ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'commit', + value: '#fff', + valueKey: 'hex', + } ); + } ); + + test( 'onChange event for value.length > 4', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onChange( { target: { value: '#aaaaaa' } } ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'commit', + value: '#aaaaaa', + valueKey: 'hex', + } ); + } ); + + test( 'onBlur', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onBlur(); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'commit', + value: '#fff', + valueKey: 'hex', + } ); + } ); + } ); + + describe( 'does call onChange with draft state', () => { + test( 'onChange event for value.length <= 4', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + source="rgb" + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onChange( { target: { value: '#aaaaa' } } ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { + source: 'rgb', + state: 'draft', + value: '#aaaaa', + valueKey: 'hex', + } ); + } ); + } ); + + describe( 'does not call onChange', () => { + test( 'onKeyDown not ENTER, DOWN, or UP', () => { + const onChange = jest.fn(); + const testInstance = TestRenderer.create( + <Input + label={ 'Color value in hexadecimal' } + valueKey="hex" + value={ '#fff' } + onChange={ onChange } + /> + ).root; + testInstance.findByType( 'input' ).props.onKeyDown( { keyCode: SPACE } ); + expect( onChange ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/color-picker/utils.js b/gb-src/packages/components/src/color-picker/utils.js new file mode 100644 index 0000000000000..dada93c31c16e --- /dev/null +++ b/gb-src/packages/components/src/color-picker/utils.js @@ -0,0 +1,212 @@ +/** + * Parts of this source were derived and modified from react-color, + * released under the MIT license. + * + * https://github.com/casesandberg/react-color/ + * + * Copyright (c) 2015 Case Sandberg + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * External dependencies + */ +import { each } from 'lodash'; +import tinycolor from 'tinycolor2'; + +/** + * Given a hex color, get all other color properties (rgb, alpha, etc). + * + * @param {Object|string} data A hex color string or an object with a hex property + * @param {string} oldHue A reference to the hue of the previous color, otherwise dragging the saturation to zero will reset the current hue to zero as well. See https://github.com/casesandberg/react-color/issues/29#issuecomment-132686909. + * @return {Object} An object of different color representations. + */ +export function colorToState( data = {}, oldHue = false ) { + const color = data.hex ? tinycolor( data.hex ) : tinycolor( data ); + const hsl = color.toHsl(); + hsl.h = Math.round( hsl.h ); + hsl.s = Math.round( hsl.s * 100 ); + hsl.l = Math.round( hsl.l * 100 ); + const hsv = color.toHsv(); + hsv.h = Math.round( hsv.h ); + hsv.s = Math.round( hsv.s * 100 ); + hsv.v = Math.round( hsv.v * 100 ); + const rgb = color.toRgb(); + const hex = color.toHex(); + if ( hsl.s === 0 ) { + hsl.h = oldHue || 0; + hsv.h = oldHue || 0; + } + const transparent = hex === '000000' && rgb.a === 0; + + return { + color, + hex: transparent ? 'transparent' : `#${ hex }`, + hsl, + hsv, + oldHue: data.h || oldHue || hsl.h, + rgb, + source: data.source, + }; +} + +/** + * Get the top/left offsets of a point in a container, also returns the container width/height. + * + * @param {Event} e Mouse or touch event with a location coordinate. + * @param {HTMLElement} container The container div, returned point is relative to this container. + * @return {Object} An object of the offset positions & container size. + */ +function getPointOffset( e, container ) { + e.preventDefault(); + const { + left: containerLeft, + top: containerTop, + width, + height, + } = container.getBoundingClientRect(); + const x = typeof e.pageX === 'number' ? e.pageX : e.touches[ 0 ].pageX; + const y = typeof e.pageY === 'number' ? e.pageY : e.touches[ 0 ].pageY; + let left = x - ( containerLeft + window.pageXOffset ); + let top = y - ( containerTop + window.pageYOffset ); + + if ( left < 0 ) { + left = 0; + } else if ( left > width ) { + left = width; + } else if ( top < 0 ) { + top = 0; + } else if ( top > height ) { + top = height; + } + + return { top, left, width, height }; +} + +/** + * Check if a string is a valid hex color code. + * + * @param {string} hex A possible hex color. + * @return {boolean} True if the color is a valid hex color. + */ +export function isValidHex( hex ) { + // disable hex4 and hex8 + const lh = String( hex ).charAt( 0 ) === '#' ? 1 : 0; + return ( + hex.length !== 4 + lh && hex.length < 7 + lh && tinycolor( hex ).isValid() + ); +} + +/** + * Check an object for any valid color properties. + * + * @param {Object} data A possible object representing a color. + * @return {Object|boolean} If a valid representation of color, returns the data object. Otherwise returns false. + */ +export function simpleCheckForValidColor( data ) { + const keysToCheck = [ 'r', 'g', 'b', 'a', 'h', 's', 'l', 'v' ]; + let checked = 0; + let passed = 0; + each( keysToCheck, ( letter ) => { + if ( data[ letter ] ) { + checked += 1; + if ( ! isNaN( data[ letter ] ) ) { + passed += 1; + } + } + } ); + return checked === passed ? data : false; +} + +/** + * Calculate the current alpha based on a mouse or touch event + * + * @param {Event} e A mouse or touch event on the alpha bar. + * @param {Object} props The current component props + * @param {HTMLElement} container The container div for the alpha bar graph. + * @return {Object|null} If the alpha value has changed, returns a new color object. + */ +export function calculateAlphaChange( e, props, container ) { + const { left, width } = getPointOffset( e, container ); + const a = left < 0 ? 0 : Math.round( ( left * 100 ) / width ) / 100; + + if ( props.hsl.a !== a ) { + return { + h: props.hsl.h, + s: props.hsl.s, + l: props.hsl.l, + a, + source: 'rgb', + }; + } + return null; +} + +/** + * Calculate the current hue based on a mouse or touch event + * + * @param {Event} e A mouse or touch event on the hue bar. + * @param {Object} props The current component props + * @param {HTMLElement} container The container div for the hue bar graph. + * @return {Object|null} If the hue value has changed, returns a new color object. + */ +export function calculateHueChange( e, props, container ) { + const { left, width } = getPointOffset( e, container ); + const percent = ( left * 100 ) / width; + const h = left >= width ? 359 : ( 360 * percent ) / 100; + + if ( props.hsl.h !== h ) { + return { + h, + s: props.hsl.s, + l: props.hsl.l, + a: props.hsl.a, + source: 'rgb', + }; + } + return null; +} + +/** + * Calculate the current saturation & brightness based on a mouse or touch event + * + * @param {Event} e A mouse or touch event on the saturation graph. + * @param {Object} props The current component props + * @param {HTMLElement} container The container div for the 2D saturation graph. + * @return {Object} Returns a new color object. + */ +export function calculateSaturationChange( e, props, container ) { + const { top, left, width, height } = getPointOffset( e, container ); + const saturation = left < 0 ? 0 : ( left * 100 ) / width; + let bright = top >= height ? 0 : -( ( top * 100 ) / height ) + 100; + // `v` values less than 1 are considered in the [0,1] range, causing unexpected behavior at the bottom + // of the chart. To fix this, we assume any value less than 1 should be 0 brightness. + if ( bright < 1 ) { + bright = 0; + } + + return { + h: props.hsl.h, + s: saturation, + v: bright, + a: props.hsl.a, + source: 'rgb', + }; +} diff --git a/gb-src/packages/components/src/dashicon/README.md b/gb-src/packages/components/src/dashicon/README.md new file mode 100644 index 0000000000000..793c5d01d38e7 --- /dev/null +++ b/gb-src/packages/components/src/dashicon/README.md @@ -0,0 +1,15 @@ +# Dashicon + +## Usage + +```jsx +import { Dashicon } from '@wordpress/components'; + +const MyDashicon = () => ( + <div> + <Dashicon icon="admin-home" /> + <Dashicon icon="products" /> + <Dashicon icon="wordpress" /> + </div> +); +``` diff --git a/gb-src/packages/components/src/dashicon/icon-class.js b/gb-src/packages/components/src/dashicon/icon-class.js new file mode 100644 index 0000000000000..d3ce7279e81e4 --- /dev/null +++ b/gb-src/packages/components/src/dashicon/icon-class.js @@ -0,0 +1,3 @@ +export const getIconClassName = ( icon, className ) => { + return [ 'dashicon', 'dashicons-' + icon, className ].filter( Boolean ).join( ' ' ); +}; diff --git a/gb-src/packages/components/src/dashicon/icon-class.native.js b/gb-src/packages/components/src/dashicon/icon-class.native.js new file mode 100644 index 0000000000000..3353a9a365238 --- /dev/null +++ b/gb-src/packages/components/src/dashicon/icon-class.native.js @@ -0,0 +1,3 @@ +export const getIconClassName = ( icon, className, ariaPressed ) => { + return [ ariaPressed ? 'dashicon-active' : 'dashicon', 'dashicons-' + icon, className ].filter( Boolean ).join( ' ' ); +}; diff --git a/gb-src/packages/components/src/dashicon/index.js b/gb-src/packages/components/src/dashicon/index.js new file mode 100644 index 0000000000000..cd7ecdd622245 --- /dev/null +++ b/gb-src/packages/components/src/dashicon/index.js @@ -0,0 +1,913 @@ +/* !!! +IF YOU ARE EDITING dashicon/index.jsx +THEN YOU ARE EDITING A FILE THAT GETS OUTPUT FROM THE DASHICONS REPO! +DO NOT EDIT THAT FILE! EDIT index-header.jsx and index-footer.jsx instead +OR if you're looking to change now SVGs get output, you'll need to edit strings in the Gruntfile :) +!!! */ + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Path, SVG } from '../primitives'; +import { getIconClassName } from './icon-class'; + +export default class Dashicon extends Component { + render() { + const { icon, size = 20, className, ariaPressed, ...extraProps } = this.props; + let path; + + switch ( icon ) { + case 'admin-appearance': + path = 'M14.48 11.06L7.41 3.99l1.5-1.5c.5-.56 2.3-.47 3.51.32 1.21.8 1.43 1.28 2.91 2.1 1.18.64 2.45 1.26 4.45.85zm-.71.71L6.7 4.7 4.93 6.47c-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39.39 1.03 0 1.42-.6.6-1.43 1.11-2.21 1.69-.35.26-.7.53-1.01.84C1.43 14.23.4 16.08 1.4 17.07c.99 1 2.84-.03 4.18-1.36.31-.31.58-.66.85-1.02.57-.78 1.08-1.61 1.69-2.21.39-.39 1.02-.39 1.41 0l1.06 1.06c.39.39 1.02.39 1.41 0z'; + break; + case 'admin-collapse': + path = 'M10 2.16c4.33 0 7.84 3.51 7.84 7.84s-3.51 7.84-7.84 7.84S2.16 14.33 2.16 10 5.71 2.16 10 2.16zm2 11.72V6.12L6.18 9.97z'; + break; + case 'admin-comments': + path = 'M5 2h9c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2h-2l-5 5v-5H5c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2z'; + break; + case 'admin-customizer': + path = 'M18.33 3.57s.27-.8-.31-1.36c-.53-.52-1.22-.24-1.22-.24-.61.3-5.76 3.47-7.67 5.57-.86.96-2.06 3.79-1.09 4.82.92.98 3.96-.17 4.79-1 2.06-2.06 5.21-7.17 5.5-7.79zM1.4 17.65c2.37-1.56 1.46-3.41 3.23-4.64.93-.65 2.22-.62 3.08.29.63.67.8 2.57-.16 3.46-1.57 1.45-4 1.55-6.15.89z'; + break; + case 'admin-generic': + path = 'M18 12h-2.18c-.17.7-.44 1.35-.81 1.93l1.54 1.54-2.1 2.1-1.54-1.54c-.58.36-1.23.63-1.91.79V19H8v-2.18c-.68-.16-1.33-.43-1.91-.79l-1.54 1.54-2.12-2.12 1.54-1.54c-.36-.58-.63-1.23-.79-1.91H1V9.03h2.17c.16-.7.44-1.35.8-1.94L2.43 5.55l2.1-2.1 1.54 1.54c.58-.37 1.24-.64 1.93-.81V2h3v2.18c.68.16 1.33.43 1.91.79l1.54-1.54 2.12 2.12-1.54 1.54c.36.59.64 1.24.8 1.94H18V12zm-8.5 1.5c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3z'; + break; + case 'admin-home': + path = 'M16 8.5l1.53 1.53-1.06 1.06L10 4.62l-6.47 6.47-1.06-1.06L10 2.5l4 4v-2h2v4zm-6-2.46l6 5.99V18H4v-5.97zM12 17v-5H8v5h4z'; + break; + case 'admin-links': + path = 'M17.74 2.76c1.68 1.69 1.68 4.41 0 6.1l-1.53 1.52c-1.12 1.12-2.7 1.47-4.14 1.09l2.62-2.61.76-.77.76-.76c.84-.84.84-2.2 0-3.04-.84-.85-2.2-.85-3.04 0l-.77.76-3.38 3.38c-.37-1.44-.02-3.02 1.1-4.14l1.52-1.53c1.69-1.68 4.42-1.68 6.1 0zM8.59 13.43l5.34-5.34c.42-.42.42-1.1 0-1.52-.44-.43-1.13-.39-1.53 0l-5.33 5.34c-.42.42-.42 1.1 0 1.52.44.43 1.13.39 1.52 0zm-.76 2.29l4.14-4.15c.38 1.44.03 3.02-1.09 4.14l-1.52 1.53c-1.69 1.68-4.41 1.68-6.1 0-1.68-1.68-1.68-4.42 0-6.1l1.53-1.52c1.12-1.12 2.7-1.47 4.14-1.1l-4.14 4.15c-.85.84-.85 2.2 0 3.05.84.84 2.2.84 3.04 0z'; + break; + case 'admin-media': + path = 'M13 11V4c0-.55-.45-1-1-1h-1.67L9 1H5L3.67 3H2c-.55 0-1 .45-1 1v7c0 .55.45 1 1 1h10c.55 0 1-.45 1-1zM7 4.5c1.38 0 2.5 1.12 2.5 2.5S8.38 9.5 7 9.5 4.5 8.38 4.5 7 5.62 4.5 7 4.5zM14 6h5v10.5c0 1.38-1.12 2.5-2.5 2.5S14 17.88 14 16.5s1.12-2.5 2.5-2.5c.17 0 .34.02.5.05V9h-3V6zm-4 8.05V13h2v3.5c0 1.38-1.12 2.5-2.5 2.5S7 17.88 7 16.5 8.12 14 9.5 14c.17 0 .34.02.5.05z'; + break; + case 'admin-multisite': + path = 'M14.27 6.87L10 3.14 5.73 6.87 5 6.14l5-4.38 5 4.38zM14 8.42l-4.05 3.43L6 8.38v-.74l4-3.5 4 3.5v.78zM11 9.7V8H9v1.7h2zm-1.73 4.03L5 10 .73 13.73 0 13l5-4.38L10 13zm10 0L15 10l-4.27 3.73L10 13l5-4.38L20 13zM5 11l4 3.5V18H1v-3.5zm10 0l4 3.5V18h-8v-3.5zm-9 6v-2H4v2h2zm10 0v-2h-2v2h2z'; + break; + case 'admin-network': + path = 'M16.95 2.58c1.96 1.95 1.96 5.12 0 7.07-1.51 1.51-3.75 1.84-5.59 1.01l-1.87 3.31-2.99.31L5 18H2l-1-2 7.95-7.69c-.92-1.87-.62-4.18.93-5.73 1.95-1.96 5.12-1.96 7.07 0zm-2.51 3.79c.74 0 1.33-.6 1.33-1.34 0-.73-.59-1.33-1.33-1.33-.73 0-1.33.6-1.33 1.33 0 .74.6 1.34 1.33 1.34z'; + break; + case 'admin-page': + path = 'M6 15V2h10v13H6zm-1 1h8v2H3V5h2v11z'; + break; + case 'admin-plugins': + path = 'M13.11 4.36L9.87 7.6 8 5.73l3.24-3.24c.35-.34 1.05-.2 1.56.32.52.51.66 1.21.31 1.55zm-8 1.77l.91-1.12 9.01 9.01-1.19.84c-.71.71-2.63 1.16-3.82 1.16H6.14L4.9 17.26c-.59.59-1.54.59-2.12 0-.59-.58-.59-1.53 0-2.12l1.24-1.24v-3.88c0-1.13.4-3.19 1.09-3.89zm7.26 3.97l3.24-3.24c.34-.35 1.04-.21 1.55.31.52.51.66 1.21.31 1.55l-3.24 3.25z'; + break; + case 'admin-post': + path = 'M10.44 3.02l1.82-1.82 6.36 6.35-1.83 1.82c-1.05-.68-2.48-.57-3.41.36l-.75.75c-.92.93-1.04 2.35-.35 3.41l-1.83 1.82-2.41-2.41-2.8 2.79c-.42.42-3.38 2.71-3.8 2.29s1.86-3.39 2.28-3.81l2.79-2.79L4.1 9.36l1.83-1.82c1.05.69 2.48.57 3.4-.36l.75-.75c.93-.92 1.05-2.35.36-3.41z'; + break; + case 'admin-settings': + path = 'M18 16V4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h13c.55 0 1-.45 1-1zM8 11h1c.55 0 1 .45 1 1s-.45 1-1 1H8v1.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5V13H6c-.55 0-1-.45-1-1s.45-1 1-1h1V5.5c0-.28.22-.5.5-.5s.5.22.5.5V11zm5-2h-1c-.55 0-1-.45-1-1s.45-1 1-1h1V5.5c0-.28.22-.5.5-.5s.5.22.5.5V7h1c.55 0 1 .45 1 1s-.45 1-1 1h-1v5.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5V9z'; + break; + case 'admin-site-alt': + path = 'M9 0C4.03 0 0 4.03 0 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm7.5 6.48c-.274.896-.908 1.64-1.75 2.05-.45-1.69-1.658-3.074-3.27-3.75.13-.444.41-.83.79-1.09-.43-.28-1-.42-1.34.07-.53.69 0 1.61.21 2v.14c-.555-.337-.99-.84-1.24-1.44-.966-.03-1.922.208-2.76.69-.087-.565-.032-1.142.16-1.68.733.07 1.453-.23 1.92-.8.46-.52-.13-1.18-.59-1.58h.36c1.36-.01 2.702.335 3.89 1 1.36 1.005 2.194 2.57 2.27 4.26.24 0 .7-.55.91-.92.172.34.32.69.44 1.05zM9 16.84c-2.05-2.08.25-3.75-1-5.24-.92-.85-2.29-.26-3.11-1.23-.282-1.473.267-2.982 1.43-3.93.52-.44 4-1 5.42.22.83.715 1.415 1.674 1.67 2.74.46.035.918-.066 1.32-.29.41 2.98-3.15 6.74-5.73 7.73zM5.15 2.09c.786-.3 1.676-.028 2.16.66-.42.38-.94.63-1.5.72.02-.294.085-.584.19-.86l-.85-.52z'; + break; + case 'admin-site-alt2': + path = 'M9 0C4.03 0 0 4.03 0 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm2.92 12.34c0 .35.14.63.36.66.22.03.47-.22.58-.6l.2.08c.718.384 1.07 1.22.84 2-.15.69-.743 1.198-1.45 1.24-.49-1.21-2.11.06-3.56-.22-.612-.154-1.11-.6-1.33-1.19 1.19-.11 2.85-1.73 4.36-1.97zM8 11.27c.918 0 1.695-.68 1.82-1.59.44.54.41 1.324-.07 1.83-.255.223-.594.325-.93.28-.335-.047-.635-.236-.82-.52zm3-.76c.41.39 3-.06 3.52 1.09-.95-.2-2.95.61-3.47-1.08l-.05-.01zM9.73 5.45v.27c-.65-.77-1.33-1.07-1.61-.57-.28.5 1 1.11.76 1.88-.24.77-1.27.56-1.88 1.61-.61 1.05-.49 2.42 1.24 3.67-1.192-.132-2.19-.962-2.54-2.11-.4-1.2-.09-2.26-.78-2.46C4 7.46 3 8.71 3 9.8c-1.26-1.26.05-2.86-1.2-4.18C3.5 1.998 7.644.223 11.44 1.49c-1.1 1.02-1.722 2.458-1.71 3.96z'; + break; + case 'admin-site-alt3': + path = 'M9 0C4.03 0 0 4.03 0 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zM1.11 9.68h2.51c.04.91.167 1.814.38 2.7H1.84c-.403-.85-.65-1.764-.73-2.7zm8.57-5.4V1.19c.964.366 1.756 1.08 2.22 2 .205.347.386.708.54 1.08l-2.76.01zm3.22 1.35c.232.883.37 1.788.41 2.7H9.68v-2.7h3.22zM8.32 1.19v3.09H5.56c.154-.372.335-.733.54-1.08.462-.924 1.255-1.64 2.22-2.01zm0 4.44v2.7H4.7c.04-.912.178-1.817.41-2.7h3.21zm-4.7 2.69H1.11c.08-.936.327-1.85.73-2.7H4c-.213.886-.34 1.79-.38 2.7zM4.7 9.68h3.62v2.7H5.11c-.232-.883-.37-1.788-.41-2.7zm3.63 4v3.09c-.964-.366-1.756-1.08-2.22-2-.205-.347-.386-.708-.54-1.08l2.76-.01zm1.35 3.09v-3.04h2.76c-.154.372-.335.733-.54 1.08-.464.92-1.256 1.634-2.22 2v-.04zm0-4.44v-2.7h3.62c-.04.912-.178 1.817-.41 2.7H9.68zm4.71-2.7h2.51c-.08.936-.327 1.85-.73 2.7H14c.21-.87.337-1.757.38-2.65l.01-.05zm0-1.35c-.046-.894-.176-1.78-.39-2.65h2.16c.403.85.65 1.764.73 2.7l-2.5-.05zm1-4H13.6c-.324-.91-.793-1.76-1.39-2.52 1.244.56 2.325 1.426 3.14 2.52h.04zm-9.6-2.52c-.597.76-1.066 1.61-1.39 2.52H2.65c.815-1.094 1.896-1.96 3.14-2.52zm-3.15 12H4.4c.324.91.793 1.76 1.39 2.52-1.248-.567-2.33-1.445-3.14-2.55l-.01.03zm9.56 2.52c.597-.76 1.066-1.61 1.39-2.52h1.76c-.82 1.08-1.9 1.933-3.14 2.48l-.01.04z'; + break; + case 'admin-site': + path = 'M9 0C4.03 0 0 4.03 0 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm3.46 11.95c0 1.47-.8 3.3-4.06 4.7.3-4.17-2.52-3.69-3.2-5 .126-1.1.804-2.063 1.8-2.55-1.552-.266-3-.96-4.18-2 .05.47.28.904.64 1.21-.782-.295-1.458-.817-1.94-1.5.977-3.225 3.883-5.482 7.25-5.63-.84 1.38-1.5 4.13 0 5.57C7.23 7 6.26 5 5.41 5.79c-1.13 1.06.33 2.51 3.42 3.08 3.29.59 3.66 1.58 3.63 3.08zm1.34-4c-.32-1.11.62-2.23 1.69-3.14 1.356 1.955 1.67 4.45.84 6.68-.77-1.89-2.17-2.32-2.53-3.57v.03z'; + break; + case 'admin-tools': + path = 'M16.68 9.77c-1.34 1.34-3.3 1.67-4.95.99l-5.41 6.52c-.99.99-2.59.99-3.58 0s-.99-2.59 0-3.57l6.52-5.42c-.68-1.65-.35-3.61.99-4.95 1.28-1.28 3.12-1.62 4.72-1.06l-2.89 2.89 2.82 2.82 2.86-2.87c.53 1.58.18 3.39-1.08 4.65zM3.81 16.21c.4.39 1.04.39 1.43 0 .4-.4.4-1.04 0-1.43-.39-.4-1.03-.4-1.43 0-.39.39-.39 1.03 0 1.43z'; + break; + case 'admin-users': + path = 'M10 9.25c-2.27 0-2.73-3.44-2.73-3.44C7 4.02 7.82 2 9.97 2c2.16 0 2.98 2.02 2.71 3.81 0 0-.41 3.44-2.68 3.44zm0 2.57L12.72 10c2.39 0 4.52 2.33 4.52 4.53v2.49s-3.65 1.13-7.24 1.13c-3.65 0-7.24-1.13-7.24-1.13v-2.49c0-2.25 1.94-4.48 4.47-4.48z'; + break; + case 'album': + path = 'M0 18h10v-.26c1.52.4 3.17.35 4.76-.24 4.14-1.52 6.27-6.12 4.75-10.26-1.43-3.89-5.58-6-9.51-4.98V2H0v16zM9 3v14H1V3h8zm5.45 8.22c-.68 1.35-2.32 1.9-3.67 1.23-.31-.15-.57-.35-.78-.59V8.13c.8-.86 2.11-1.13 3.22-.58 1.35.68 1.9 2.32 1.23 3.67zm-2.75-.82c.22.16.53.12.7-.1.16-.22.12-.53-.1-.7s-.53-.12-.7.1c-.16.21-.12.53.1.7zm3.01 3.67c-1.17.78-2.56.99-3.83.69-.27-.06-.44-.34-.37-.61s.34-.43.62-.36l.17.04c.96.17 1.98-.01 2.86-.59.47-.32.86-.72 1.14-1.18.15-.23.45-.3.69-.16.23.15.3.46.16.69-.36.57-.84 1.08-1.44 1.48zm1.05 1.57c-1.48.99-3.21 1.32-4.84 1.06-.28-.05-.47-.32-.41-.6.05-.27.32-.45.61-.39l.22.04c1.31.15 2.68-.14 3.87-.94.71-.47 1.27-1.07 1.7-1.74.14-.24.45-.31.68-.16.24.14.31.45.16.69-.49.79-1.16 1.49-1.99 2.04z'; + break; + case 'align-center': + path = 'M3 5h14V3H3v2zm12 8V7H5v6h10zM3 17h14v-2H3v2z'; + break; + case 'align-full-width': + path = 'M17 13V3H3v10h14zM5 17h10v-2H5v2z'; + break; + case 'align-left': + path = 'M3 5h14V3H3v2zm9 8V7H3v6h9zm2-4h3V7h-3v2zm0 4h3v-2h-3v2zM3 17h14v-2H3v2z'; + break; + case 'align-none': + path = 'M3 5h14V3H3v2zm10 8V7H3v6h10zM3 17h14v-2H3v2z'; + break; + case 'align-pull-left': + path = 'M9 16V4H3v12h6zm2-7h6V7h-6v2zm0 4h6v-2h-6v2z'; + break; + case 'align-pull-right': + path = 'M17 16V4h-6v12h6zM9 7H3v2h6V7zm0 4H3v2h6v-2z'; + break; + case 'align-right': + path = 'M3 5h14V3H3v2zm0 4h3V7H3v2zm14 4V7H8v6h9zM3 13h3v-2H3v2zm0 4h14v-2H3v2z'; + break; + case 'align-wide': + path = 'M5 5h10V3H5v2zm12 8V7H3v6h14zM5 17h10v-2H5v2z'; + break; + case 'analytics': + path = 'M18 18V2H2v16h16zM16 5H4V4h12v1zM7 7v3h3c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3zm1 2V7c1.1 0 2 .9 2 2H8zm8-1h-4V7h4v1zm0 3h-4V9h4v2zm0 2h-4v-1h4v1zm0 3H4v-1h12v1z'; + break; + case 'archive': + path = 'M19 4v2H1V4h18zM2 7h16v10H2V7zm11 3V9H7v1h6z'; + break; + case 'arrow-down-alt': + path = 'M9 2h2v12l4-4 2 1-7 7-7-7 2-1 4 4V2z'; + break; + case 'arrow-down-alt2': + path = 'M5 6l5 5 5-5 2 1-7 7-7-7z'; + break; + case 'arrow-down': + path = 'M15 8l-4.03 6L7 8h8z'; + break; + case 'arrow-left-alt': + path = 'M18 9v2H6l4 4-1 2-7-7 7-7 1 2-4 4h12z'; + break; + case 'arrow-left-alt2': + path = 'M14 5l-5 5 5 5-1 2-7-7 7-7z'; + break; + case 'arrow-left': + path = 'M13 14L7 9.97 13 6v8z'; + break; + case 'arrow-right-alt': + path = 'M2 11V9h12l-4-4 1-2 7 7-7 7-1-2 4-4H2z'; + break; + case 'arrow-right-alt2': + path = 'M6 15l5-5-5-5 1-2 7 7-7 7z'; + break; + case 'arrow-right': + path = 'M8 6l6 4.03L8 14V6z'; + break; + case 'arrow-up-alt': + path = 'M11 18H9V6l-4 4-2-1 7-7 7 7-2 1-4-4v12z'; + break; + case 'arrow-up-alt2': + path = 'M15 14l-5-5-5 5-2-1 7-7 7 7z'; + break; + case 'arrow-up': + path = 'M7 13l4.03-6L15 13H7z'; + break; + case 'art': + path = 'M8.55 3.06c1.01.34-1.95 2.01-.1 3.13 1.04.63 3.31-2.22 4.45-2.86.97-.54 2.67-.65 3.53 1.23 1.09 2.38.14 8.57-3.79 11.06-3.97 2.5-8.97 1.23-10.7-2.66-2.01-4.53 3.12-11.09 6.61-9.9zm1.21 6.45c.73 1.64 4.7-.5 3.79-2.8-.59-1.49-4.48 1.25-3.79 2.8z'; + break; + case 'awards': + path = 'M4.46 5.16L5 7.46l-.54 2.29 2.01 1.24L7.7 13l2.3-.54 2.3.54 1.23-2.01 2.01-1.24L15 7.46l.54-2.3-2-1.24-1.24-2.01-2.3.55-2.29-.54-1.25 2zm5.55 6.34C7.79 11.5 6 9.71 6 7.49c0-2.2 1.79-3.99 4.01-3.99 2.2 0 3.99 1.79 3.99 3.99 0 2.22-1.79 4.01-3.99 4.01zm-.02-1C8.33 10.5 7 9.16 7 7.5c0-1.65 1.33-3 2.99-3S13 5.85 13 7.5c0 1.66-1.35 3-3.01 3zm3.84 1.1l-1.28 2.24-2.08-.47L13 19.2l1.4-2.2h2.5zm-7.7.07l1.25 2.25 2.13-.51L7 19.2 5.6 17H3.1z'; + break; + case 'backup': + path = 'M13.65 2.88c3.93 2.01 5.48 6.84 3.47 10.77s-6.83 5.48-10.77 3.47c-1.87-.96-3.2-2.56-3.86-4.4l1.64-1.03c.45 1.57 1.52 2.95 3.08 3.76 3.01 1.54 6.69.35 8.23-2.66 1.55-3.01.36-6.69-2.65-8.24C9.78 3.01 6.1 4.2 4.56 7.21l1.88.97-4.95 3.08-.39-5.82 1.78.91C4.9 2.4 9.75.89 13.65 2.88zm-4.36 7.83C9.11 10.53 9 10.28 9 10c0-.07.03-.12.04-.19h-.01L10 5l.97 4.81L14 13l-4.5-2.12.02-.02c-.08-.04-.16-.09-.23-.15z'; + break; + case 'block-default': + path = 'M15 6V4h-3v2H8V4H5v2H4c-.6 0-1 .4-1 1v8h14V7c0-.6-.4-1-1-1h-1z'; + break; + case 'book-alt': + path = 'M5 17h13v2H5c-1.66 0-3-1.34-3-3V4c0-1.66 1.34-3 3-3h13v14H5c-.55 0-1 .45-1 1s.45 1 1 1zm2-3.5v-11c0-.28-.22-.5-.5-.5s-.5.22-.5.5v11c0 .28.22.5.5.5s.5-.22.5-.5z'; + break; + case 'book': + path = 'M16 3h2v16H5c-1.66 0-3-1.34-3-3V4c0-1.66 1.34-3 3-3h9v14H5c-.55 0-1 .45-1 1s.45 1 1 1h11V3z'; + break; + case 'buddicons-activity': + path = 'M8 1v7h2V6c0-1.52 1.45-3 3-3v.86c.55-.52 1.26-.86 2-.86v3h1c1.1 0 2 .9 2 2s-.9 2-2 2h-1v6c0 .55-.45 1-1 1s-1-.45-1-1v-2.18c-.31.11-.65.18-1 .18v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H8v2c0 .55-.45 1-1 1s-1-.45-1-1v-2c-.35 0-.69-.07-1-.18V16c0 .55-.45 1-1 1s-1-.45-1-1v-4H2v-1c0-1.66 1.34-3 3-3h2V1h1zm5 7c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1z'; + break; + case 'buddicons-bbpress-logo': + path = 'M8.5 12.6c.3-1.3 0-2.3-1.1-2.3-.8 0-1.6.6-1.8 1.5l-.3 1.7c-.3 1 .3 1.5 1 1.5 1.2 0 1.9-1.1 2.2-2.4zm-4-6.4C3.7 7.3 3.3 8.6 3.3 10c0 1 .2 1.9.6 2.8l1-4.6c.3-1.7.4-2-.4-2zm9.3 6.4c.3-1.3 0-2.3-1.1-2.3-.8 0-1.6.6-1.8 1.5l-.4 1.7c-.2 1.1.4 1.6 1.1 1.6 1.1-.1 1.9-1.2 2.2-2.5zM10 3.3c-2 0-3.9.9-5.1 2.3.6-.1 1.4-.2 1.8-.3.2 0 .2.1.2.2 0 .2-1 4.8-1 4.8.5-.3 1.2-.7 1.8-.7.9 0 1.5.4 1.9.9l.5-2.4c.4-1.6.4-1.9-.4-1.9-.4 0-.4-.5 0-.6.6-.1 1.8-.2 2.3-.3.2 0 .2.1.2.2l-1 4.8c.5-.4 1.2-.7 1.9-.7 1.7 0 2.5 1.3 2.1 3-.3 1.7-2 3-3.8 3-1.3 0-2.1-.7-2.3-1.4-.7.8-1.7 1.3-2.8 1.4 1.1.7 2.4 1.1 3.7 1.1 3.7 0 6.7-3 6.7-6.7s-3-6.7-6.7-6.7zM10 2c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm0 15.5c-2.1 0-4-.8-5.3-2.2-.3-.4-.7-.8-1-1.2-.7-1.2-1.2-2.6-1.2-4.1 0-4.1 3.4-7.5 7.5-7.5s7.5 3.4 7.5 7.5-3.4 7.5-7.5 7.5z'; + break; + case 'buddicons-buddypress-logo': + path = 'M10 0c5.52 0 10 4.48 10 10s-4.48 10-10 10S0 15.52 0 10 4.48 0 10 0zm0 .5C4.75.5.5 4.75.5 10s4.25 9.5 9.5 9.5 9.5-4.25 9.5-9.5S15.25.5 10 .5zm0 1c4.7 0 8.5 3.8 8.5 8.5s-3.8 8.5-8.5 8.5-8.5-3.8-8.5-8.5S5.3 1.5 10 1.5zm1.8 1.71c-.57 0-1.1.17-1.55.45 1.56.37 2.73 1.77 2.73 3.45 0 .69-.21 1.33-.55 1.87 1.31-.29 2.29-1.45 2.29-2.85 0-1.61-1.31-2.92-2.92-2.92zm-2.38 1c-1.61 0-2.92 1.31-2.92 2.93 0 1.61 1.31 2.92 2.92 2.92 1.62 0 2.93-1.31 2.93-2.92 0-1.62-1.31-2.93-2.93-2.93zm4.25 5.01l-.51.59c2.34.69 2.45 3.61 2.45 3.61h1.28c0-4.71-3.22-4.2-3.22-4.2zm-2.1.8l-2.12 2.09-2.12-2.09C3.12 10.24 3.89 15 3.89 15h11.08c.47-4.98-3.4-4.98-3.4-4.98z'; + break; + case 'buddicons-community': + path = 'M9 3c0-.67-.47-1.43-1-2-.5.5-1 1.38-1 2 0 .48.45 1 1 1s1-.47 1-1zm4 0c0-.67-.47-1.43-1-2-.5.5-1 1.38-1 2 0 .48.45 1 1 1s1-.47 1-1zM9 9V5.5c0-.55-.45-1-1-1-.57 0-1 .49-1 1V9c0 .55.45 1 1 1 .57 0 1-.49 1-1zm4 0V5.5c0-.55-.45-1-1-1-.57 0-1 .49-1 1V9c0 .55.45 1 1 1 .57 0 1-.49 1-1zm4 1c0-1.48-1.41-2.77-3.5-3.46V9c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5V6.01c-.17 0-.33-.01-.5-.01s-.33.01-.5.01V9c0 .83-.67 1.5-1.5 1.5S6.5 9.83 6.5 9V6.54C4.41 7.23 3 8.52 3 10c0 1.41.95 2.65 3.21 3.37 1.11.35 2.39 1.12 3.79 1.12s2.69-.78 3.79-1.13C16.04 12.65 17 11.41 17 10zm-7 5.43c1.43 0 2.74-.79 3.88-1.11 1.9-.53 2.49-1.34 3.12-2.32v3c0 2.21-3.13 4-7 4s-7-1.79-7-4v-3c.64.99 1.32 1.8 3.15 2.33 1.13.33 2.44 1.1 3.85 1.1z'; + break; + case 'buddicons-forums': + path = 'M13.5 7h-7C5.67 7 5 6.33 5 5.5S5.67 4 6.5 4h1.59C8.04 3.84 8 3.68 8 3.5 8 2.67 8.67 2 9.5 2h1c.83 0 1.5.67 1.5 1.5 0 .18-.04.34-.09.5h1.59c.83 0 1.5.67 1.5 1.5S14.33 7 13.5 7zM4 8h12c.55 0 1 .45 1 1s-.45 1-1 1H4c-.55 0-1-.45-1-1s.45-1 1-1zm1 3h10c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1s.45-1 1-1zm2 3h6c.55 0 1 .45 1 1s-.45 1-1 1h-1.09c.05.16.09.32.09.5 0 .83-.67 1.5-1.5 1.5h-1c-.83 0-1.5-.67-1.5-1.5 0-.18.04-.34.09-.5H7c-.55 0-1-.45-1-1s.45-1 1-1z'; + break; + case 'buddicons-friends': + path = 'M8.75 5.77C8.75 4.39 7 2 7 2S5.25 4.39 5.25 5.77 5.9 7.5 7 7.5s1.75-.35 1.75-1.73zm6 0C14.75 4.39 13 2 13 2s-1.75 2.39-1.75 3.77S11.9 7.5 13 7.5s1.75-.35 1.75-1.73zM9 17V9c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1v8c0 .55.45 1 1 1h2c.55 0 1-.45 1-1zm6 0V9c0-.55-.45-1-1-1h-2c-.55 0-1 .45-1 1v8c0 .55.45 1 1 1h2c.55 0 1-.45 1-1zm-9-6l2-1v2l-2 1v-2zm6 0l2-1v2l-2 1v-2zm-6 3l2-1v2l-2 1v-2zm6 0l2-1v2l-2 1v-2z'; + break; + case 'buddicons-groups': + path = 'M15.45 6.25c1.83.94 1.98 3.18.7 4.98-.8 1.12-2.33 1.88-3.46 1.78L10.05 18H9l-2.65-4.99c-1.13.16-2.73-.63-3.55-1.79-1.28-1.8-1.13-4.04.71-4.97.48-.24.96-.33 1.43-.31-.01.4.01.8.07 1.21.26 1.69 1.41 3.53 2.86 4.37-.19.55-.49.99-.88 1.25L9 16.58v-5.66C7.64 10.55 6.26 8.76 6 7c-.4-2.65 1-5 3.5-5s3.9 2.35 3.5 5c-.26 1.76-1.64 3.55-3 3.92v5.77l2.07-3.84c-.44-.23-.77-.71-.99-1.3 1.48-.83 2.65-2.69 2.91-4.4.06-.41.08-.82.07-1.22.46-.01.92.08 1.39.32z'; + break; + case 'buddicons-pm': + path = 'M10 2c3 0 8 5 8 5v11H2V7s5-5 8-5zm7 14.72l-3.73-2.92L17 11l-.43-.37-2.26 1.3.24-4.31-8.77-.52-.46 4.54-1.99-.95L3 11l3.73 2.8-3.44 2.85.4.43L10 13l6.53 4.15z'; + break; + case 'buddicons-replies': + path = 'M17.54 10.29c1.17 1.17 1.17 3.08 0 4.25-1.18 1.17-3.08 1.17-4.25 0l-.34-.52c0 3.66-2 4.38-2.95 4.98-.82-.6-2.95-1.28-2.95-4.98l-.34.52c-1.17 1.17-3.07 1.17-4.25 0-1.17-1.17-1.17-3.08 0-4.25 0 0 1.02-.67 2.1-1.3C3.71 7.84 3.2 6.42 3.2 4.88c0-.34.03-.67.08-1C3.53 5.66 4.47 7.22 5.8 8.3c.67-.35 1.85-.83 2.37-.92H8c-1.1 0-2-.9-2-2s.9-2 2-2v-.5c0-.28.22-.5.5-.5s.5.22.5.5v.5h2v-.5c0-.28.22-.5.5-.5s.5.22.5.5v.5c1.1 0 2 .9 2 2s-.9 2-2 2h-.17c.51.09 1.78.61 2.38.92 1.33-1.08 2.27-2.64 2.52-4.42.05.33.08.66.08 1 0 1.54-.51 2.96-1.36 4.11 1.08.63 2.09 1.3 2.09 1.3zM8.5 6.38c.5 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm3-2c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm-2.3 5.73c-.12.11-.19.26-.19.43.02.25.23.46.49.46h1c.26 0 .47-.21.49-.46 0-.15-.07-.29-.19-.43-.08-.06-.18-.11-.3-.11h-1c-.12 0-.22.05-.3.11zM12 12.5c0-.12-.06-.28-.19-.38-.09-.07-.19-.12-.31-.12h-3c-.12 0-.22.05-.31.12-.11.1-.19.25-.19.38 0 .28.22.5.5.5h3c.28 0 .5-.22.5-.5zM8.5 15h3c.28 0 .5-.22.5-.5s-.22-.5-.5-.5h-3c-.28 0-.5.22-.5.5s.22.5.5.5zm1 2h1c.28 0 .5-.22.5-.5s-.22-.5-.5-.5h-1c-.28 0-.5.22-.5.5s.22.5.5.5z'; + break; + case 'buddicons-topics': + path = 'M10.44 1.66c-.59-.58-1.54-.58-2.12 0L2.66 7.32c-.58.58-.58 1.53 0 2.12.6.6 1.56.56 2.12 0l5.66-5.66c.58-.58.59-1.53 0-2.12zm2.83 2.83c-.59-.59-1.54-.59-2.12 0l-5.66 5.66c-.59.58-.59 1.53 0 2.12.6.6 1.56.55 2.12 0l5.66-5.66c.58-.58.58-1.53 0-2.12zm1.06 6.72l4.18 4.18c.59.58.59 1.53 0 2.12s-1.54.59-2.12 0l-4.18-4.18-1.77 1.77c-.59.58-1.54.58-2.12 0-.59-.59-.59-1.54 0-2.13l5.66-5.65c.58-.59 1.53-.59 2.12 0 .58.58.58 1.53 0 2.12zM5 15c0-1.59-1.66-4-1.66-4S2 13.78 2 15s.6 2 1.34 2h.32C4.4 17 5 16.59 5 15z'; + break; + case 'buddicons-tracking': + path = 'M10.98 6.78L15.5 15c-1 2-3.5 3-5.5 3s-4.5-1-5.5-3L9 6.82c-.75-1.23-2.28-1.98-4.29-2.03l2.46-2.92c1.68 1.19 2.46 2.32 2.97 3.31.56-.87 1.2-1.68 2.7-2.12l1.83 2.86c-1.42-.34-2.64.08-3.69.86zM8.17 10.4l-.93 1.69c.49.11 1 .16 1.54.16 1.35 0 2.58-.36 3.55-.95l-1.01-1.82c-.87.53-1.96.86-3.15.92zm.86 5.38c1.99 0 3.73-.74 4.74-1.86l-.98-1.76c-1 1.12-2.74 1.87-4.74 1.87-.62 0-1.21-.08-1.76-.21l-.63 1.15c.94.5 2.1.81 3.37.81z'; + break; + case 'building': + path = 'M3 20h14V0H3v20zM7 3H5V1h2v2zm4 0H9V1h2v2zm4 0h-2V1h2v2zM7 6H5V4h2v2zm4 0H9V4h2v2zm4 0h-2V4h2v2zM7 9H5V7h2v2zm4 0H9V7h2v2zm4 0h-2V7h2v2zm-8 3H5v-2h2v2zm4 0H9v-2h2v2zm4 0h-2v-2h2v2zm-4 7H5v-6h6v6zm4-4h-2v-2h2v2zm0 3h-2v-2h2v2z'; + break; + case 'businessman': + path = 'M7.3 6l-.03-.19c-.04-.37-.05-.73-.03-1.08.02-.36.1-.71.25-1.04.14-.32.31-.61.52-.86s.49-.46.83-.6c.34-.15.72-.23 1.13-.23.69 0 1.26.2 1.71.59s.76.87.91 1.44.18 1.16.09 1.78l-.03.19c-.01.09-.05.25-.11.48-.05.24-.12.47-.2.69-.08.21-.19.45-.34.72-.14.27-.3.49-.47.69-.18.19-.4.34-.67.48-.27.13-.55.19-.86.19s-.59-.06-.87-.19c-.26-.13-.49-.29-.67-.5-.18-.2-.34-.42-.49-.66-.15-.25-.26-.49-.34-.73-.09-.25-.16-.47-.21-.67-.06-.21-.1-.37-.12-.5zm9.2 6.24c.41.7.5 1.41.5 2.14v2.49c0 .03-.12.08-.29.13-.18.04-.42.13-.97.27-.55.12-1.1.24-1.65.34s-1.19.19-1.95.27c-.75.08-1.46.12-2.13.12-.68 0-1.39-.04-2.14-.12-.75-.07-1.4-.17-1.98-.27-.58-.11-1.08-.23-1.56-.34-.49-.11-.8-.21-1.06-.29L3 16.87v-2.49c0-.75.07-1.46.46-2.15s.81-1.25 1.5-1.68C5.66 10.12 7.19 10 8 10l1.67 1.67L9 13v3l1.02 1.08L11 16v-3l-.68-1.33L11.97 10c.77 0 2.2.07 2.9.52.71.45 1.21 1.02 1.63 1.72z'; + break; + case 'button': + path = 'M17 5H3c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm1 7c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1h14c.6 0 1 .4 1 1v5z'; + break; + case 'calendar-alt': + path = 'M15 4h3v15H2V4h3V3c0-.41.15-.76.44-1.06.29-.29.65-.44 1.06-.44s.77.15 1.06.44c.29.3.44.65.44 1.06v1h4V3c0-.41.15-.76.44-1.06.29-.29.65-.44 1.06-.44s.77.15 1.06.44c.29.3.44.65.44 1.06v1zM6 3v2.5c0 .14.05.26.15.36.09.09.21.14.35.14s.26-.05.35-.14c.1-.1.15-.22.15-.36V3c0-.14-.05-.26-.15-.35-.09-.1-.21-.15-.35-.15s-.26.05-.35.15c-.1.09-.15.21-.15.35zm7 0v2.5c0 .14.05.26.14.36.1.09.22.14.36.14s.26-.05.36-.14c.09-.1.14-.22.14-.36V3c0-.14-.05-.26-.14-.35-.1-.1-.22-.15-.36-.15s-.26.05-.36.15c-.09.09-.14.21-.14.35zm4 15V8H3v10h14zM7 9v2H5V9h2zm2 0h2v2H9V9zm4 2V9h2v2h-2zm-6 1v2H5v-2h2zm2 0h2v2H9v-2zm4 2v-2h2v2h-2zm-6 1v2H5v-2h2zm4 2H9v-2h2v2zm4 0h-2v-2h2v2z'; + break; + case 'calendar': + path = 'M15 4h3v14H2V4h3V3c0-.83.67-1.5 1.5-1.5S8 2.17 8 3v1h4V3c0-.83.67-1.5 1.5-1.5S15 2.17 15 3v1zM6 3v2.5c0 .28.22.5.5.5s.5-.22.5-.5V3c0-.28-.22-.5-.5-.5S6 2.72 6 3zm7 0v2.5c0 .28.22.5.5.5s.5-.22.5-.5V3c0-.28-.22-.5-.5-.5s-.5.22-.5.5zm4 14V8H3v9h14zM7 16V9H5v7h2zm4 0V9H9v7h2zm4 0V9h-2v7h2z'; + break; + case 'camera': + path = 'M6 5V3H3v2h3zm12 10V4H9L7 6H2v9h16zm-7-8c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z'; + break; + case 'carrot': + path = 'M2 18.43c1.51 1.36 11.64-4.67 13.14-7.21.72-1.22-.13-3.01-1.52-4.44C15.2 5.73 16.59 9 17.91 8.31c.6-.32.99-1.31.7-1.92-.52-1.08-2.25-1.08-3.42-1.21.83-.2 2.82-1.05 2.86-2.25.04-.92-1.13-1.97-2.05-1.86-1.21.14-1.65 1.88-2.06 3-.05-.71-.2-2.27-.98-2.95-1.04-.91-2.29-.05-2.32 1.05-.04 1.33 2.82 2.07 1.92 3.67C11.04 4.67 9.25 4.03 8.1 4.7c-.49.31-1.05.91-1.63 1.69.89.94 2.12 2.07 3.09 2.72.2.14.26.42.11.62-.14.21-.42.26-.62.12-.99-.67-2.2-1.78-3.1-2.71-.45.67-.91 1.43-1.34 2.23.85.86 1.93 1.83 2.79 2.41.2.14.25.42.11.62-.14.21-.42.26-.63.12-.85-.58-1.86-1.48-2.71-2.32C2.4 13.69 1.1 17.63 2 18.43z'; + break; + case 'cart': + path = 'M6 13h9c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1V4H2c-.55 0-1-.45-1-1s.45-1 1-1h3c.55 0 1 .45 1 1v2h13l-4 7H6v1zm-.5 3c.83 0 1.5.67 1.5 1.5S6.33 19 5.5 19 4 18.33 4 17.5 4.67 16 5.5 16zm9 0c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5z'; + break; + case 'category': + path = 'M5 7h13v10H2V4h7l2 2H4v9h1V7z'; + break; + case 'chart-area': + path = 'M18 18l.01-12.28c.59-.35.99-.99.99-1.72 0-1.1-.9-2-2-2s-2 .9-2 2c0 .8.47 1.48 1.14 1.8l-4.13 6.58c-.33-.24-.73-.38-1.16-.38-.84 0-1.55.51-1.85 1.24l-2.14-1.53c.09-.22.14-.46.14-.71 0-1.11-.89-2-2-2-1.1 0-2 .89-2 2 0 .73.4 1.36.98 1.71L1 18h17zM17 3c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM5 10c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5.85 3c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z'; + break; + case 'chart-bar': + path = 'M18 18V2h-4v16h4zm-6 0V7H8v11h4zm-6 0v-8H2v8h4z'; + break; + case 'chart-line': + path = 'M18 3.5c0 .62-.38 1.16-.92 1.38v13.11H1.99l4.22-6.73c-.13-.23-.21-.48-.21-.76C6 9.67 6.67 9 7.5 9S9 9.67 9 10.5c0 .13-.02.25-.05.37l1.44.63c.27-.3.67-.5 1.11-.5.18 0 .35.04.51.09l3.58-6.41c-.36-.27-.59-.7-.59-1.18 0-.83.67-1.5 1.5-1.5.19 0 .36.04.53.1l.05-.09v.11c.54.22.92.76.92 1.38zm-1.92 13.49V5.85l-3.29 5.89c.13.23.21.48.21.76 0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5l.01-.07-1.63-.72c-.25.18-.55.29-.88.29-.18 0-.35-.04-.51-.1l-3.2 5.09h12.29z'; + break; + case 'chart-pie': + path = 'M10 10V3c3.87 0 7 3.13 7 7h-7zM9 4v7h7c0 3.87-3.13 7-7 7s-7-3.13-7-7 3.13-7 7-7z'; + break; + case 'clipboard': + path = 'M11.9.39l1.4 1.4c1.61.19 3.5-.74 4.61.37s.18 3 .37 4.61l1.4 1.4c.39.39.39 1.02 0 1.41l-9.19 9.2c-.4.39-1.03.39-1.42 0L1.29 11c-.39-.39-.39-1.02 0-1.42l9.2-9.19c.39-.39 1.02-.39 1.41 0zm.58 2.25l-.58.58 4.95 4.95.58-.58c-.19-.6-.2-1.22-.15-1.82.02-.31.05-.62.09-.92.12-1 .18-1.63-.17-1.98s-.98-.29-1.98-.17c-.3.04-.61.07-.92.09-.6.05-1.22.04-1.82-.15zm4.02.93c.39.39.39 1.03 0 1.42s-1.03.39-1.42 0-.39-1.03 0-1.42 1.03-.39 1.42 0zm-6.72.36l-.71.7L15.44 11l.7-.71zM8.36 5.34l-.7.71 6.36 6.36.71-.7zM6.95 6.76l-.71.7 6.37 6.37.7-.71zM5.54 8.17l-.71.71 6.36 6.36.71-.71zM4.12 9.58l-.71.71 6.37 6.37.71-.71z'; + break; + case 'clock': + path = 'M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm0 14c3.31 0 6-2.69 6-6s-2.69-6-6-6-6 2.69-6 6 2.69 6 6 6zm-.71-5.29c.07.05.14.1.23.15l-.02.02L14 13l-3.03-3.19L10 5l-.97 4.81h.01c0 .02-.01.05-.02.09S9 9.97 9 10c0 .28.1.52.29.71z'; + break; + case 'cloud-saved': + path = 'M14.8 9c.1-.3.2-.6.2-1 0-2.2-1.8-4-4-4-1.5 0-2.9.9-3.5 2.2-.3-.1-.7-.2-1-.2C5.1 6 4 7.1 4 8.5c0 .2 0 .4.1.5-1.8.3-3.1 1.7-3.1 3.5C1 14.4 2.6 16 4.5 16h10c1.9 0 3.5-1.6 3.5-3.5 0-1.8-1.4-3.3-3.2-3.5zm-6.3 5.9l-3.2-3.2 1.4-1.4 1.8 1.8 3.8-3.8 1.4 1.4-5.2 5.2z'; + break; + case 'cloud-upload': + path = 'M14.8 9c.1-.3.2-.6.2-1 0-2.2-1.8-4-4-4-1.5 0-2.9.9-3.5 2.2-.3-.1-.7-.2-1-.2C5.1 6 4 7.1 4 8.5c0 .2 0 .4.1.5-1.8.3-3.1 1.7-3.1 3.5C1 14.4 2.6 16 4.5 16H8v-3H5l4.5-4.5L14 13h-3v3h3.5c1.9 0 3.5-1.6 3.5-3.5 0-1.8-1.4-3.3-3.2-3.5z'; + break; + case 'cloud': + path = 'M14.9 9c1.8.2 3.1 1.7 3.1 3.5 0 1.9-1.6 3.5-3.5 3.5h-10C2.6 16 1 14.4 1 12.5 1 10.7 2.3 9.3 4.1 9 4 8.9 4 8.7 4 8.5 4 7.1 5.1 6 6.5 6c.3 0 .7.1.9.2C8.1 4.9 9.4 4 11 4c2.2 0 4 1.8 4 4 0 .4-.1.7-.1 1z'; + break; + case 'columns': + path = 'M3 15h6V5H3v10zm8 0h6V5h-6v10z'; + break; + case 'controls-back': + path = 'M2 10l10-6v3.6L18 4v12l-6-3.6V16z'; + break; + case 'controls-forward': + path = 'M18 10L8 16v-3.6L2 16V4l6 3.6V4z'; + break; + case 'controls-pause': + path = 'M5 16V4h3v12H5zm7-12h3v12h-3V4z'; + break; + case 'controls-play': + path = 'M5 4l10 6-10 6V4z'; + break; + case 'controls-repeat': + path = 'M5 7v3l-2 1.5V5h11V3l4 3.01L14 9V7H5zm10 6v-3l2-1.5V15H6v2l-4-3.01L6 11v2h9z'; + break; + case 'controls-skipback': + path = 'M11.98 7.63l6-3.6v12l-6-3.6v3.6l-8-4.8v4.8h-2v-12h2v4.8l8-4.8v3.6z'; + break; + case 'controls-skipforward': + path = 'M8 12.4L2 16V4l6 3.6V4l8 4.8V4h2v12h-2v-4.8L8 16v-3.6z'; + break; + case 'controls-volumeoff': + path = 'M2 7h4l5-4v14l-5-4H2V7z'; + break; + case 'controls-volumeon': + path = 'M2 7h4l5-4v14l-5-4H2V7zm12.69-2.46C14.82 4.59 18 5.92 18 10s-3.18 5.41-3.31 5.46c-.06.03-.13.04-.19.04-.2 0-.39-.12-.46-.31-.11-.26.02-.55.27-.65.11-.05 2.69-1.15 2.69-4.54 0-3.41-2.66-4.53-2.69-4.54-.25-.1-.38-.39-.27-.65.1-.25.39-.38.65-.27zM16 10c0 2.57-2.23 3.43-2.32 3.47-.06.02-.12.03-.18.03-.2 0-.39-.12-.47-.32-.1-.26.04-.55.29-.65.07-.02 1.68-.67 1.68-2.53s-1.61-2.51-1.68-2.53c-.25-.1-.38-.39-.29-.65.1-.25.39-.39.65-.29.09.04 2.32.9 2.32 3.47z'; + break; + case 'cover-image': + path = 'M2.2 1h15.5c.7 0 1.3.6 1.3 1.2v11.5c0 .7-.6 1.2-1.2 1.2H2.2c-.6.1-1.2-.5-1.2-1.1V2.2C1 1.6 1.6 1 2.2 1zM17 13V3H3v10h14zm-4-4s0-5 3-5v7c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1V7c2 0 3 4 3 4s1-4 3-4 3 2 3 2zM4 17h12v2H4z'; + break; + case 'dashboard': + path = 'M3.76 16h12.48c1.1-1.37 1.76-3.11 1.76-5 0-4.42-3.58-8-8-8s-8 3.58-8 8c0 1.89.66 3.63 1.76 5zM10 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM6 6c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm8 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm-5.37 5.55L12 7v6c0 1.1-.9 2-2 2s-2-.9-2-2c0-.57.24-1.08.63-1.45zM4 10c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm12 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm-5 3c0-.55-.45-1-1-1s-1 .45-1 1 .45 1 1 1 1-.45 1-1z'; + break; + case 'desktop': + path = 'M3 2h14c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1h-5v2h2c.55 0 1 .45 1 1v1H5v-1c0-.55.45-1 1-1h2v-2H3c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1zm13 9V4H4v7h12zM5 5h9L5 9V5z'; + break; + case 'dismiss': + path = 'M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm5 11l-3-3 3-3-2-2-3 3-3-3-2 2 3 3-3 3 2 2 3-3 3 3z'; + break; + case 'download': + path = 'M14.01 4v6h2V2H4v8h2.01V4h8zm-2 2v6h3l-5 6-5-6h3V6h4z'; + break; + case 'edit': + path = 'M13.89 3.39l2.71 2.72c.46.46.42 1.24.03 1.64l-8.01 8.02-5.56 1.16 1.16-5.58s7.6-7.63 7.99-8.03c.39-.39 1.22-.39 1.68.07zm-2.73 2.79l-5.59 5.61 1.11 1.11 5.54-5.65zm-2.97 8.23l5.58-5.6-1.07-1.08-5.59 5.6z'; + break; + case 'editor-aligncenter': + path = 'M14 5V3H6v2h8zm3 4V7H3v2h14zm-3 4v-2H6v2h8zm3 4v-2H3v2h14z'; + break; + case 'editor-alignleft': + path = 'M12 5V3H3v2h9zm5 4V7H3v2h14zm-5 4v-2H3v2h9zm5 4v-2H3v2h14z'; + break; + case 'editor-alignright': + path = 'M17 5V3H8v2h9zm0 4V7H3v2h14zm0 4v-2H8v2h9zm0 4v-2H3v2h14z'; + break; + case 'editor-bold': + path = 'M6 4v13h4.54c1.37 0 2.46-.33 3.26-1 .8-.66 1.2-1.58 1.2-2.77 0-.84-.17-1.51-.51-2.01s-.9-.85-1.67-1.03v-.09c.57-.1 1.02-.4 1.36-.9s.51-1.13.51-1.91c0-1.14-.39-1.98-1.17-2.5C12.75 4.26 11.5 4 9.78 4H6zm2.57 5.15V6.26h1.36c.73 0 1.27.11 1.61.32.34.22.51.58.51 1.07 0 .54-.16.92-.47 1.15s-.82.35-1.51.35h-1.5zm0 2.19h1.6c1.44 0 2.16.53 2.16 1.61 0 .6-.17 1.05-.51 1.34s-.86.43-1.57.43H8.57v-3.38z'; + break; + case 'editor-break': + path = 'M16 4h2v9H7v3l-5-4 5-4v3h9V4z'; + break; + case 'editor-code': + path = 'M9 6l-4 4 4 4-1 2-6-6 6-6zm2 8l4-4-4-4 1-2 6 6-6 6z'; + break; + case 'editor-contract': + path = 'M15.75 6.75L18 3v14l-2.25-3.75L17 12h-4v4l1.25-1.25L18 17H2l3.75-2.25L7 16v-4H3l1.25 1.25L2 17V3l2.25 3.75L3 8h4V4L5.75 5.25 2 3h16l-3.75 2.25L13 4v4h4z'; + break; + case 'editor-customchar': + path = 'M10 5.4c1.27 0 2.24.36 2.91 1.08.66.71 1 1.76 1 3.13 0 1.28-.23 2.37-.69 3.27-.47.89-1.27 1.52-2.22 2.12v2h6v-2h-3.69c.92-.64 1.62-1.34 2.12-2.34.49-1.01.74-2.13.74-3.35 0-1.78-.55-3.19-1.65-4.22S11.92 3.54 10 3.54s-3.43.53-4.52 1.57c-1.1 1.04-1.65 2.44-1.65 4.2 0 1.21.24 2.31.73 3.33.48 1.01 1.19 1.71 2.1 2.36H3v2h6v-2c-.98-.64-1.8-1.28-2.24-2.17-.45-.89-.67-1.96-.67-3.22 0-1.37.33-2.41 1-3.13C7.75 5.76 8.72 5.4 10 5.4z'; + break; + case 'editor-expand': + path = 'M7 8h6v4H7zm-5 5v4h4l-1.2-1.2L7 12l-3.8 2.2M14 17h4v-4l-1.2 1.2L13 12l2.2 3.8M14 3l1.3 1.3L13 8l3.8-2.2L18 7V3M6 3H2v4l1.2-1.2L7 8 4.7 4.3'; + break; + case 'editor-help': + path = 'M17 10c0-3.87-3.14-7-7-7-3.87 0-7 3.13-7 7s3.13 7 7 7c3.86 0 7-3.13 7-7zm-6.3 1.48H9.14v-.43c0-.38.08-.7.24-.98s.46-.57.88-.89c.41-.29.68-.53.81-.71.14-.18.2-.39.2-.62 0-.25-.09-.44-.28-.58-.19-.13-.45-.19-.79-.19-.58 0-1.25.19-2 .57l-.64-1.28c.87-.49 1.8-.74 2.77-.74.81 0 1.45.2 1.92.58.48.39.71.91.71 1.55 0 .43-.09.8-.29 1.11-.19.32-.57.67-1.11 1.06-.38.28-.61.49-.71.63-.1.15-.15.34-.15.57v.35zm-1.47 2.74c-.18-.17-.27-.42-.27-.73 0-.33.08-.58.26-.75s.43-.25.77-.25c.32 0 .57.09.75.26s.27.42.27.74c0 .3-.09.55-.27.72-.18.18-.43.27-.75.27-.33 0-.58-.09-.76-.26z'; + break; + case 'editor-indent': + path = 'M3 5V3h9v2H3zm10-1V3h4v1h-4zm0 3h2V5l4 3.5-4 3.5v-2h-2V7zM3 8V6h9v2H3zm2 3V9h7v2H5zm-2 3v-2h9v2H3zm10 0v-1h4v1h-4zm-4 3v-2h3v2H9z'; + break; + case 'editor-insertmore': + path = 'M17 7V3H3v4h14zM6 11V9H3v2h3zm6 0V9H8v2h4zm5 0V9h-3v2h3zm0 6v-4H3v4h14z'; + break; + case 'editor-italic': + path = 'M14.78 6h-2.13l-2.8 9h2.12l-.62 2H4.6l.62-2h2.14l2.8-9H8.03l.62-2h6.75z'; + break; + case 'editor-justify': + path = 'M2 3h16v2H2V3zm0 4h16v2H2V7zm0 4h16v2H2v-2zm0 4h16v2H2v-2z'; + break; + case 'editor-kitchensink': + path = 'M19 2v6H1V2h18zm-1 5V3H2v4h16zM5 4v2H3V4h2zm3 0v2H6V4h2zm3 0v2H9V4h2zm3 0v2h-2V4h2zm3 0v2h-2V4h2zm2 5v9H1V9h18zm-1 8v-7H2v7h16zM5 11v2H3v-2h2zm3 0v2H6v-2h2zm3 0v2H9v-2h2zm6 0v2h-5v-2h5zm-6 3v2H3v-2h8zm3 0v2h-2v-2h2zm3 0v2h-2v-2h2z'; + break; + case 'editor-ltr': + path = 'M5.52 2h7.43c.55 0 1 .45 1 1s-.45 1-1 1h-1v13c0 .55-.45 1-1 1s-1-.45-1-1V5c0-.55-.45-1-1-1s-1 .45-1 1v12c0 .55-.45 1-1 1s-1-.45-1-1v-5.96h-.43C3.02 11.04 1 9.02 1 6.52S3.02 2 5.52 2zM14 14l5-4-5-4v8z'; + break; + case 'editor-ol-rtl': + path = 'M15.025 8.75a1.048 1.048 0 0 1 .45-.1.507.507 0 0 1 .35.11.455.455 0 0 1 .13.36.803.803 0 0 1-.06.3 1.448 1.448 0 0 1-.19.33c-.09.11-.29.32-.58.62l-.99 1v.58h2.76v-.7h-1.72v-.04l.51-.48a7.276 7.276 0 0 0 .7-.71 1.75 1.75 0 0 0 .3-.49 1.254 1.254 0 0 0 .1-.51.968.968 0 0 0-.16-.56 1.007 1.007 0 0 0-.44-.37 1.512 1.512 0 0 0-.65-.14 1.98 1.98 0 0 0-.51.06 1.9 1.9 0 0 0-.42.15 3.67 3.67 0 0 0-.48.35l.45.54a2.505 2.505 0 0 1 .45-.3zM16.695 15.29a1.29 1.29 0 0 0-.74-.3v-.02a1.203 1.203 0 0 0 .65-.37.973.973 0 0 0 .23-.65.81.81 0 0 0-.37-.71 1.72 1.72 0 0 0-1-.26 2.185 2.185 0 0 0-1.33.4l.4.6a1.79 1.79 0 0 1 .46-.23 1.18 1.18 0 0 1 .41-.07c.38 0 .58.15.58.46a.447.447 0 0 1-.22.43 1.543 1.543 0 0 1-.7.12h-.31v.66h.31a1.764 1.764 0 0 1 .75.12.433.433 0 0 1 .23.41.55.55 0 0 1-.2.47 1.084 1.084 0 0 1-.63.15 2.24 2.24 0 0 1-.57-.08 2.671 2.671 0 0 1-.52-.2v.74a2.923 2.923 0 0 0 1.18.22 1.948 1.948 0 0 0 1.22-.33 1.077 1.077 0 0 0 .43-.92.836.836 0 0 0-.26-.64zM15.005 4.17c.06-.05.16-.14.3-.28l-.02.42V7h.84V3h-.69l-1.29 1.03.4.51zM4.02 5h9v1h-9zM4.02 10h9v1h-9zM4.02 15h9v1h-9z'; + break; + case 'editor-ol': + path = 'M6 7V3h-.69L4.02 4.03l.4.51.46-.37c.06-.05.16-.14.3-.28l-.02.42V7H6zm2-2h9v1H8V5zm-1.23 6.95v-.7H5.05v-.04l.51-.48c.33-.31.57-.54.7-.71.14-.17.24-.33.3-.49.07-.16.1-.33.1-.51 0-.21-.05-.4-.16-.56-.1-.16-.25-.28-.44-.37s-.41-.14-.65-.14c-.19 0-.36.02-.51.06-.15.03-.29.09-.42.15-.12.07-.29.19-.48.35l.45.54c.16-.13.31-.23.45-.3.15-.07.3-.1.45-.1.14 0 .26.03.35.11s.13.2.13.36c0 .1-.02.2-.06.3s-.1.21-.19.33c-.09.11-.29.32-.58.62l-.99 1v.58h2.76zM8 10h9v1H8v-1zm-1.29 3.95c0-.3-.12-.54-.37-.71-.24-.17-.58-.26-1-.26-.52 0-.96.13-1.33.4l.4.6c.17-.11.32-.19.46-.23.14-.05.27-.07.41-.07.38 0 .58.15.58.46 0 .2-.07.35-.22.43s-.38.12-.7.12h-.31v.66h.31c.34 0 .59.04.75.12.15.08.23.22.23.41 0 .22-.07.37-.2.47-.14.1-.35.15-.63.15-.19 0-.38-.03-.57-.08s-.36-.12-.52-.2v.74c.34.15.74.22 1.18.22.53 0 .94-.11 1.22-.33.29-.22.43-.52.43-.92 0-.27-.09-.48-.26-.64s-.42-.26-.74-.3v-.02c.27-.06.49-.19.65-.37.15-.18.23-.39.23-.65zM8 15h9v1H8v-1z'; + break; + case 'editor-outdent': + path = 'M7 4V3H3v1h4zm10 1V3H8v2h9zM7 7H5V5L1 8.5 5 12v-2h2V7zm10 1V6H8v2h9zm-2 3V9H8v2h7zm2 3v-2H8v2h9zM7 14v-1H3v1h4zm4 3v-2H8v2h3z'; + break; + case 'editor-paragraph': + path = 'M15 2H7.54c-.83 0-1.59.2-2.28.6-.7.41-1.25.96-1.65 1.65C3.2 4.94 3 5.7 3 6.52s.2 1.58.61 2.27c.4.69.95 1.24 1.65 1.64.69.41 1.45.61 2.28.61h.43V17c0 .27.1.51.29.71.2.19.44.29.71.29.28 0 .51-.1.71-.29.2-.2.3-.44.3-.71V5c0-.27.09-.51.29-.71.2-.19.44-.29.71-.29s.51.1.71.29c.19.2.29.44.29.71v12c0 .27.1.51.3.71.2.19.43.29.71.29.27 0 .51-.1.71-.29.19-.2.29-.44.29-.71V4H15c.27 0 .5-.1.7-.3.2-.19.3-.43.3-.7s-.1-.51-.3-.71C15.5 2.1 15.27 2 15 2z'; + break; + case 'editor-paste-text': + path = 'M12.38 2L15 5v1H5V5l2.64-3h4.74zM10 5c.55 0 1-.44 1-1 0-.55-.45-1-1-1s-1 .45-1 1c0 .56.45 1 1 1zm5.45-1H17c.55 0 1 .45 1 1v12c0 .56-.45 1-1 1H3c-.55 0-1-.44-1-1V5c0-.55.45-1 1-1h1.55L4 4.63V7h12V4.63zM14 11V9H6v2h3v5h2v-5h3z'; + break; + case 'editor-paste-word': + path = 'M12.38 2L15 5v1H5V5l2.64-3h4.74zM10 5c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm8 12V5c0-.55-.45-1-1-1h-1.54l.54.63V7H4V4.62L4.55 4H3c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h14c.55 0 1-.45 1-1zm-3-8l-2 7h-2l-1-5-1 5H6.92L5 9h2l1 5 1-5h2l1 5 1-5h2z'; + break; + case 'editor-quote': + path = 'M9.49 13.22c0-.74-.2-1.38-.61-1.9-.62-.78-1.83-.88-2.53-.72-.29-1.65 1.11-3.75 2.92-4.65L7.88 4c-2.73 1.3-5.42 4.28-4.96 8.05C3.21 14.43 4.59 16 6.54 16c.85 0 1.56-.25 2.12-.75s.83-1.18.83-2.03zm8.05 0c0-.74-.2-1.38-.61-1.9-.63-.78-1.83-.88-2.53-.72-.29-1.65 1.11-3.75 2.92-4.65L15.93 4c-2.73 1.3-5.41 4.28-4.95 8.05.29 2.38 1.66 3.95 3.61 3.95.85 0 1.56-.25 2.12-.75s.83-1.18.83-2.03z'; + break; + case 'editor-removeformatting': + path = 'M14.29 4.59l1.1 1.11c.41.4.61.94.61 1.47v2.12c0 .53-.2 1.07-.61 1.47l-6.63 6.63c-.4.41-.94.61-1.47.61s-1.07-.2-1.47-.61l-1.11-1.1-1.1-1.11c-.41-.4-.61-.94-.61-1.47v-2.12c0-.54.2-1.07.61-1.48l6.63-6.62c.4-.41.94-.61 1.47-.61s1.06.2 1.47.61zm-6.21 9.7l6.42-6.42c.39-.39.39-1.03 0-1.43L12.36 4.3c-.19-.19-.45-.29-.72-.29s-.52.1-.71.29l-6.42 6.42c-.39.4-.39 1.04 0 1.43l2.14 2.14c.38.38 1.04.38 1.43 0z'; + break; + case 'editor-rtl': + path = 'M5.52 2h7.43c.55 0 1 .45 1 1s-.45 1-1 1h-1v13c0 .55-.45 1-1 1s-1-.45-1-1V5c0-.55-.45-1-1-1s-1 .45-1 1v12c0 .55-.45 1-1 1s-1-.45-1-1v-5.96h-.43C3.02 11.04 1 9.02 1 6.52S3.02 2 5.52 2zM19 6l-5 4 5 4V6z'; + break; + case 'editor-spellcheck': + path = 'M15.84 2.76c.25 0 .49.04.71.11.23.07.44.16.64.25l.35-.81c-.52-.26-1.08-.39-1.69-.39-.58 0-1.09.13-1.52.37-.43.25-.76.61-.99 1.08C13.11 3.83 13 4.38 13 5c0 .99.23 1.75.7 2.28s1.15.79 2.02.79c.6 0 1.13-.09 1.6-.26v-.84c-.26.08-.51.14-.74.19-.24.05-.49.08-.74.08-.59 0-1.04-.19-1.34-.57-.32-.37-.47-.93-.47-1.66 0-.7.16-1.25.48-1.65.33-.4.77-.6 1.33-.6zM6.5 8h1.04L5.3 2H4.24L2 8h1.03l.58-1.66H5.9zM8 2v6h2.17c.67 0 1.19-.15 1.57-.46.38-.3.56-.72.56-1.26 0-.4-.1-.72-.3-.95-.19-.24-.5-.39-.93-.47v-.04c.35-.06.6-.21.78-.44.18-.24.28-.53.28-.88 0-.52-.19-.9-.56-1.14-.36-.24-.96-.36-1.79-.36H8zm.98 2.48V2.82h.85c.44 0 .77.06.97.19.21.12.31.33.31.61 0 .31-.1.53-.29.66-.18.13-.48.2-.89.2h-.95zM5.64 5.5H3.9l.54-1.56c.14-.4.25-.76.32-1.1l.15.52c.07.23.13.4.17.51zm3.34-.23h.99c.44 0 .76.08.98.23.21.15.32.38.32.69 0 .34-.11.59-.32.75s-.52.24-.93.24H8.98V5.27zM4 13l5 5 9-8-1-1-8 6-4-3z'; + break; + case 'editor-strikethrough': + path = 'M15.82 12.25c.26 0 .5-.02.74-.07.23-.05.48-.12.73-.2v.84c-.46.17-.99.26-1.58.26-.88 0-1.54-.26-2.01-.79-.39-.44-.62-1.04-.68-1.79h-.94c.12.21.18.48.18.79 0 .54-.18.95-.55 1.26-.38.3-.9.45-1.56.45H8v-2.5H6.59l.93 2.5H6.49l-.59-1.67H3.62L3.04 13H2l.93-2.5H2v-1h1.31l.93-2.49H5.3l.92 2.49H8V7h1.77c1 0 1.41.17 1.77.41.37.24.55.62.55 1.13 0 .35-.09.64-.27.87l-.08.09h1.29c.05-.4.15-.77.31-1.1.23-.46.55-.82.98-1.06.43-.25.93-.37 1.51-.37.61 0 1.17.12 1.69.38l-.35.81c-.2-.1-.42-.18-.64-.25s-.46-.11-.71-.11c-.55 0-.99.2-1.31.59-.23.29-.38.66-.44 1.11H17v1h-2.95c.06.5.2.9.44 1.19.3.37.75.56 1.33.56zM4.44 8.96l-.18.54H5.3l-.22-.61c-.04-.11-.09-.28-.17-.51-.07-.24-.12-.41-.14-.51-.08.33-.18.69-.33 1.09zm4.53-1.09V9.5h1.19c.28-.02.49-.09.64-.18.19-.13.28-.35.28-.66 0-.28-.1-.48-.3-.61-.2-.12-.53-.18-.97-.18h-.84zm-3.33 2.64v-.01H3.91v.01h1.73zm5.28.01l-.03-.02H8.97v1.68h1.04c.4 0 .71-.08.92-.23.21-.16.31-.4.31-.74 0-.31-.11-.54-.32-.69z'; + break; + case 'editor-table': + path = 'M18 17V3H2v14h16zM16 7H4V5h12v2zm-7 4H4V9h5v2zm7 0h-5V9h5v2zm-7 4H4v-2h5v2zm7 0h-5v-2h5v2z'; + break; + case 'editor-textcolor': + path = 'M13.23 15h1.9L11 4H9L5 15h1.88l1.07-3h4.18zm-1.53-4.54H8.51L10 5.6z'; + break; + case 'editor-ul': + path = 'M5.5 7C4.67 7 4 6.33 4 5.5 4 4.68 4.67 4 5.5 4 6.32 4 7 4.68 7 5.5 7 6.33 6.32 7 5.5 7zM8 5h9v1H8V5zm-2.5 7c-.83 0-1.5-.67-1.5-1.5C4 9.68 4.67 9 5.5 9c.82 0 1.5.68 1.5 1.5 0 .83-.68 1.5-1.5 1.5zM8 10h9v1H8v-1zm-2.5 7c-.83 0-1.5-.67-1.5-1.5 0-.82.67-1.5 1.5-1.5.82 0 1.5.68 1.5 1.5 0 .83-.68 1.5-1.5 1.5zM8 15h9v1H8v-1z'; + break; + case 'editor-underline': + path = 'M14 5h-2v5.71c0 1.99-1.12 2.98-2.45 2.98-1.32 0-2.55-1-2.55-2.96V5H5v5.87c0 1.91 1 4.54 4.48 4.54 3.49 0 4.52-2.58 4.52-4.5V5zm0 13v-2H5v2h9z'; + break; + case 'editor-unlink': + path = 'M17.74 2.26c1.68 1.69 1.68 4.41 0 6.1l-1.53 1.52c-.32.33-.69.58-1.08.77L13 10l1.69-1.64.76-.77.76-.76c.84-.84.84-2.2 0-3.04-.84-.85-2.2-.85-3.04 0l-.77.76-.76.76L10 7l-.65-2.14c.19-.38.44-.75.77-1.07l1.52-1.53c1.69-1.68 4.42-1.68 6.1 0zM2 4l8 6-6-8zm4-2l4 8-2-8H6zM2 6l8 4-8-2V6zm7.36 7.69L10 13l.74 2.35-1.38 1.39c-1.69 1.68-4.41 1.68-6.1 0-1.68-1.68-1.68-4.42 0-6.1l1.39-1.38L7 10l-.69.64-1.52 1.53c-.85.84-.85 2.2 0 3.04.84.85 2.2.85 3.04 0zM18 16l-8-6 6 8zm-4 2l-4-8 2 8h2zm4-4l-8-4 8 2v2z'; + break; + case 'editor-video': + path = 'M16 2h-3v1H7V2H4v15h3v-1h6v1h3V2zM6 3v1H5V3h1zm9 0v1h-1V3h1zm-2 1v5H7V4h6zM6 5v1H5V5h1zm9 0v1h-1V5h1zM6 7v1H5V7h1zm9 0v1h-1V7h1zM6 9v1H5V9h1zm9 0v1h-1V9h1zm-2 1v5H7v-5h6zm-7 1v1H5v-1h1zm9 0v1h-1v-1h1zm-9 2v1H5v-1h1zm9 0v1h-1v-1h1zm-9 2v1H5v-1h1zm9 0v1h-1v-1h1z'; + break; + case 'ellipsis': + path = 'M5 10c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-7 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z'; + break; + case 'email-alt': + path = 'M19 14.5v-9c0-.83-.67-1.5-1.5-1.5H3.49c-.83 0-1.5.67-1.5 1.5v9c0 .83.67 1.5 1.5 1.5H17.5c.83 0 1.5-.67 1.5-1.5zm-1.31-9.11c.33.33.15.67-.03.84L13.6 9.95l3.9 4.06c.12.14.2.36.06.51-.13.16-.43.15-.56.05l-4.37-3.73-2.14 1.95-2.13-1.95-4.37 3.73c-.13.1-.43.11-.56-.05-.14-.15-.06-.37.06-.51l3.9-4.06-4.06-3.72c-.18-.17-.36-.51-.03-.84s.67-.17.95.07l6.24 5.04 6.25-5.04c.28-.24.62-.4.95-.07z'; + break; + case 'email-alt2': + path = 'M18.01 11.18V2.51c0-1.19-.9-1.81-2-1.37L4 5.91c-1.1.44-2 1.77-2 2.97v8.66c0 1.2.9 1.81 2 1.37l12.01-4.77c1.1-.44 2-1.76 2-2.96zm-1.43-7.46l-6.04 9.33-6.65-4.6c-.1-.07-.36-.32-.17-.64.21-.36.65-.21.65-.21l6.3 2.32s4.83-6.34 5.11-6.7c.13-.17.43-.34.73-.13.29.2.16.49.07.63z'; + break; + case 'email': + path = 'M3.87 4h13.25C18.37 4 19 4.59 19 5.79v8.42c0 1.19-.63 1.79-1.88 1.79H3.87c-1.25 0-1.88-.6-1.88-1.79V5.79c0-1.2.63-1.79 1.88-1.79zm6.62 8.6l6.74-5.53c.24-.2.43-.66.13-1.07-.29-.41-.82-.42-1.17-.17l-5.7 3.86L4.8 5.83c-.35-.25-.88-.24-1.17.17-.3.41-.11.87.13 1.07z'; + break; + case 'embed-audio': + path = 'M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 3H7v4c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.4 0 .7.1 1 .3V5h4v2zm4 3.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z'; + break; + case 'embed-generic': + path = 'M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-3 6.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z'; + break; + case 'embed-photo': + path = 'M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 8H3V6h7v6zm4-1.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3zm-6-4V8.5L7.2 10 6 9.2 4 11h5zM4.6 8.6c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z'; + break; + case 'embed-post': + path = 'M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM8.6 9l-.4.3c-.4.4-.5 1.1-.2 1.6l-.8.8-1.1-1.1-1.3 1.3c-.2.2-1.6 1.3-1.8 1.1-.2-.2.9-1.6 1.1-1.8l1.3-1.3-1.1-1.1.8-.8c.5.3 1.2.3 1.6-.2l.3-.3c.5-.5.5-1.2.2-1.7L8 5l3 2.9-.8.8c-.5-.2-1.2-.2-1.6.3zm5.4 1.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z'; + break; + case 'embed-video': + path = 'M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 6.5L8 9.1V11H3V6h5v1.8l2-1.3v4zm4 0L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z'; + break; + case 'excerpt-view': + path = 'M19 18V2c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v16c0 .55.45 1 1 1h16c.55 0 1-.45 1-1zM4 3c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v6H6V3h11zM4 11c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v6H6v-6h11z'; + break; + case 'exit': + path = 'M13 3v2h2v10h-2v2h4V3h-4zm0 8V9H5.4l4.3-4.3-1.4-1.4L1.6 10l6.7 6.7 1.4-1.4L5.4 11H13z'; + break; + case 'external': + path = 'M9 3h8v8l-2-1V6.92l-5.6 5.59-1.41-1.41L14.08 5H10zm3 12v-3l2-2v7H3V6h8L9 8H5v7h7z'; + break; + case 'facebook-alt': + path = 'M8.46 18h2.93v-7.3h2.45l.37-2.84h-2.82V6.04c0-.82.23-1.38 1.41-1.38h1.51V2.11c-.26-.03-1.15-.11-2.19-.11-2.18 0-3.66 1.33-3.66 3.76v2.1H6v2.84h2.46V18z'; + break; + case 'facebook': + path = 'M2.89 2h14.23c.49 0 .88.39.88.88v14.24c0 .48-.39.88-.88.88h-4.08v-6.2h2.08l.31-2.41h-2.39V7.85c0-.7.2-1.18 1.2-1.18h1.28V4.51c-.22-.03-.98-.09-1.86-.09-1.85 0-3.11 1.12-3.11 3.19v1.78H8.46v2.41h2.09V18H2.89c-.49 0-.89-.4-.89-.88V2.88c0-.49.4-.88.89-.88z'; + break; + case 'feedback': + path = 'M2 2h16c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1H2c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1zm15 14V7H3v9h14zM4 8v1h3V8H4zm4 0v3h8V8H8zm-4 4v1h3v-1H4zm4 0v3h8v-3H8z'; + break; + case 'filter': + path = 'M3 4.5v-2s3.34-1 7-1 7 1 7 1v2l-5 7.03v6.97s-1.22-.09-2.25-.59S8 16.5 8 16.5v-4.97z'; + break; + case 'flag': + path = 'M5 18V3H3v15h2zm1-6V4c3-1 7 1 11 0v8c-3 1.27-8-1-11 0z'; + break; + case 'format-aside': + path = 'M1 1h18v12l-6 6H1V1zm3 3v1h12V4H4zm0 4v1h12V8H4zm6 5v-1H4v1h6zm2 4l5-5h-5v5z'; + break; + case 'format-audio': + path = 'M6.99 3.08l11.02-2c.55-.08.99.45.99 1V14.5c0 1.94-1.57 3.5-3.5 3.5S12 16.44 12 14.5c0-1.93 1.57-3.5 3.5-3.5.54 0 1.04.14 1.5.35V5.08l-9 2V16c-.24 1.7-1.74 3-3.5 3C2.57 19 1 17.44 1 15.5 1 13.57 2.57 12 4.5 12c.54 0 1.04.14 1.5.35V4.08c0-.55.44-.91.99-1z'; + break; + case 'format-chat': + path = 'M11 6h-.82C9.07 6 8 7.2 8 8.16V10l-3 3v-3H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v3zm0 1h6c1.1 0 2 .9 2 2v5c0 1.1-.9 2-2 2h-2v3l-3-3h-1c-1.1 0-2-.9-2-2V9c0-1.1.9-2 2-2z'; + break; + case 'format-gallery': + path = 'M16 4h1.96c.57 0 1.04.47 1.04 1.04v12.92c0 .57-.47 1.04-1.04 1.04H5.04C4.47 19 4 18.53 4 17.96V16H2.04C1.47 16 1 15.53 1 14.96V2.04C1 1.47 1.47 1 2.04 1h12.92c.57 0 1.04.47 1.04 1.04V4zM3 14h11V3H3v11zm5-8.5C8 4.67 7.33 4 6.5 4S5 4.67 5 5.5 5.67 7 6.5 7 8 6.33 8 5.5zm2 4.5s1-5 3-5v8H4V7c2 0 2 3 2 3s.33-2 2-2 2 2 2 2zm7 7V6h-1v8.96c0 .57-.47 1.04-1.04 1.04H6v1h11z'; + break; + case 'format-image': + path = 'M2.25 1h15.5c.69 0 1.25.56 1.25 1.25v15.5c0 .69-.56 1.25-1.25 1.25H2.25C1.56 19 1 18.44 1 17.75V2.25C1 1.56 1.56 1 2.25 1zM17 17V3H3v14h14zM10 6c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm3 5s0-6 3-6v10c0 .55-.45 1-1 1H5c-.55 0-1-.45-1-1V8c2 0 3 4 3 4s1-3 3-3 3 2 3 2z'; + break; + case 'format-quote': + path = 'M8.54 12.74c0-.87-.24-1.61-.72-2.22-.73-.92-2.14-1.03-2.96-.85-.34-1.93 1.3-4.39 3.42-5.45L6.65 1.94C3.45 3.46.31 6.96.85 11.37 1.19 14.16 2.8 16 5.08 16c1 0 1.83-.29 2.48-.88.66-.59.98-1.38.98-2.38zm9.43 0c0-.87-.24-1.61-.72-2.22-.73-.92-2.14-1.03-2.96-.85-.34-1.93 1.3-4.39 3.42-5.45l-1.63-2.28c-3.2 1.52-6.34 5.02-5.8 9.43.34 2.79 1.95 4.63 4.23 4.63 1 0 1.83-.29 2.48-.88.66-.59.98-1.38.98-2.38z'; + break; + case 'format-status': + path = 'M10 1c7 0 9 2.91 9 6.5S17 14 10 14s-9-2.91-9-6.5S3 1 10 1zM5.5 9C6.33 9 7 8.33 7 7.5S6.33 6 5.5 6 4 6.67 4 7.5 4.67 9 5.5 9zM10 9c.83 0 1.5-.67 1.5-1.5S10.83 6 10 6s-1.5.67-1.5 1.5S9.17 9 10 9zm4.5 0c.83 0 1.5-.67 1.5-1.5S15.33 6 14.5 6 13 6.67 13 7.5 13.67 9 14.5 9zM6 14.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm-3 2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z'; + break; + case 'format-video': + path = 'M2 1h16c.55 0 1 .45 1 1v16l-18-.02V2c0-.55.45-1 1-1zm4 1L4 5h1l2-3H6zm4 0H9L7 5h1zm3 0h-1l-2 3h1zm3 0h-1l-2 3h1zm1 14V6H3v10h14zM8 7l6 4-6 4V7z'; + break; + case 'forms': + path = 'M2 2h7v7H2V2zm9 0v7h7V2h-7zM5.5 4.5L7 3H4zM12 8V3h5v5h-5zM4.5 5.5L3 4v3zM8 4L6.5 5.5 8 7V4zM5.5 6.5L4 8h3zM9 18v-7H2v7h7zm9 0h-7v-7h7v7zM8 12v5H3v-5h5zm6.5 1.5L16 12h-3zM12 16l1.5-1.5L12 13v3zm3.5-1.5L17 16v-3zm-1 1L13 17h3z'; + break; + case 'googleplus': + path = 'M6.73 10h5.4c.05.29.09.57.09.95 0 3.27-2.19 5.6-5.49 5.6-3.17 0-5.73-2.57-5.73-5.73 0-3.17 2.56-5.73 5.73-5.73 1.54 0 2.84.57 3.83 1.5l-1.55 1.5c-.43-.41-1.17-.89-2.28-.89-1.96 0-3.55 1.62-3.55 3.62 0 1.99 1.59 3.61 3.55 3.61 2.26 0 3.11-1.62 3.24-2.47H6.73V10zM19 10v1.64h-1.64v1.63h-1.63v-1.63h-1.64V10h1.64V8.36h1.63V10H19z'; + break; + case 'grid-view': + path = 'M2 1h16c.55 0 1 .45 1 1v16c0 .55-.45 1-1 1H2c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1zm7.01 7.99v-6H3v6h6.01zm8 0v-6h-6v6h6zm-8 8.01v-6H3v6h6.01zm8 0v-6h-6v6h6z'; + break; + case 'groups': + path = 'M8.03 4.46c-.29 1.28.55 3.46 1.97 3.46 1.41 0 2.25-2.18 1.96-3.46-.22-.98-1.08-1.63-1.96-1.63-.89 0-1.74.65-1.97 1.63zm-4.13.9c-.25 1.08.47 2.93 1.67 2.93s1.92-1.85 1.67-2.93c-.19-.83-.92-1.39-1.67-1.39s-1.48.56-1.67 1.39zm8.86 0c-.25 1.08.47 2.93 1.66 2.93 1.2 0 1.92-1.85 1.67-2.93-.19-.83-.92-1.39-1.67-1.39-.74 0-1.47.56-1.66 1.39zm-.59 11.43l1.25-4.3C14.2 10 12.71 8.47 10 8.47c-2.72 0-4.21 1.53-3.44 4.02l1.26 4.3C8.05 17.51 9 18 10 18c.98 0 1.94-.49 2.17-1.21zm-6.1-7.63c-.49.67-.96 1.83-.42 3.59l1.12 3.79c-.34.2-.77.31-1.2.31-.85 0-1.65-.41-1.85-1.03l-1.07-3.65c-.65-2.11.61-3.4 2.92-3.4.27 0 .54.02.79.06-.1.1-.2.22-.29.33zm8.35-.39c2.31 0 3.58 1.29 2.92 3.4l-1.07 3.65c-.2.62-1 1.03-1.85 1.03-.43 0-.86-.11-1.2-.31l1.11-3.77c.55-1.78.08-2.94-.42-3.61-.08-.11-.18-.23-.28-.33.25-.04.51-.06.79-.06z'; + break; + case 'hammer': + path = 'M17.7 6.32l1.41 1.42-3.47 3.41-1.42-1.42.84-.82c-.32-.76-.81-1.57-1.51-2.31l-4.61 6.59-5.26 4.7c-.39.39-1.02.39-1.42 0l-1.2-1.21c-.39-.39-.39-1.02 0-1.41l10.97-9.92c-1.37-.86-3.21-1.46-5.67-1.48 2.7-.82 4.95-.93 6.58-.3 1.7.66 2.82 2.2 3.91 3.58z'; + break; + case 'heading': + path = 'M12.5 4v5.2h-5V4H5v13h2.5v-5.2h5V17H15V4'; + break; + case 'heart': + path = 'M10 17.12c3.33-1.4 5.74-3.79 7.04-6.21 1.28-2.41 1.46-4.81.32-6.25-1.03-1.29-2.37-1.78-3.73-1.74s-2.68.63-3.63 1.46c-.95-.83-2.27-1.42-3.63-1.46s-2.7.45-3.73 1.74c-1.14 1.44-.96 3.84.34 6.25 1.28 2.42 3.69 4.81 7.02 6.21z'; + break; + case 'hidden': + path = 'M17.2 3.3l.16.17c.39.39.39 1.02 0 1.41L4.55 17.7c-.39.39-1.03.39-1.41 0l-.17-.17c-.39-.39-.39-1.02 0-1.41l1.59-1.6c-1.57-1-2.76-2.3-3.56-3.93.81-1.65 2.03-2.98 3.64-3.99S8.04 5.09 10 5.09c1.2 0 2.33.21 3.4.6l2.38-2.39c.39-.39 1.03-.39 1.42 0zm-7.09 4.01c-.23.25-.34.54-.34.88 0 .31.12.58.31.81l1.8-1.79c-.13-.12-.28-.21-.45-.26-.11-.01-.28-.03-.49-.04-.33.03-.6.16-.83.4zM2.4 10.59c.69 1.23 1.71 2.25 3.05 3.05l1.28-1.28c-.51-.69-.77-1.47-.77-2.36 0-1.06.36-1.98 1.09-2.76-1.04.27-1.96.7-2.76 1.26-.8.58-1.43 1.27-1.89 2.09zm13.22-2.13l.96-.96c1.02.86 1.83 1.89 2.42 3.09-.81 1.65-2.03 2.98-3.64 3.99s-3.4 1.51-5.36 1.51c-.63 0-1.24-.07-1.83-.18l1.07-1.07c.25.02.5.05.76.05 1.63 0 3.13-.4 4.5-1.21s2.4-1.84 3.1-3.09c-.46-.82-1.09-1.51-1.89-2.09-.03-.01-.06-.03-.09-.04zm-5.58 5.58l4-4c-.01 1.1-.41 2.04-1.18 2.81-.78.78-1.72 1.18-2.82 1.19z'; + break; + case 'html': + path = 'M4 16v-2H2v2H1v-5h1v2h2v-2h1v5H4zM7 16v-4H5.6v-1h3.7v1H8v4H7zM10 16v-5h1l1.4 3.4h.1L14 11h1v5h-1v-3.1h-.1l-1.1 2.5h-.6l-1.1-2.5H11V16h-1zM19 16h-3v-5h1v4h2v1zM9.4 4.2L7.1 6.5l2.3 2.3-.6 1.2-3.5-3.5L8.8 3l.6 1.2zm1.2 4.6l2.3-2.3-2.3-2.3.6-1.2 3.5 3.5-3.5 3.5-.6-1.2z'; + break; + case 'id-alt': + path = 'M18 18H2V2h16v16zM8.05 7.53c.13-.07.24-.15.33-.24.09-.1.17-.21.24-.34.07-.14.13-.26.17-.37s.07-.22.1-.34L8.95 6c0-.04.01-.07.01-.09.05-.32.03-.61-.04-.9-.08-.28-.23-.52-.46-.72C8.23 4.1 7.95 4 7.6 4c-.2 0-.39.04-.56.11-.17.08-.31.18-.41.3-.11.13-.2.27-.27.44-.07.16-.11.33-.12.51s0 .36.01.55l.02.09c.01.06.03.15.06.25s.06.21.1.33.1.25.17.37c.08.12.16.23.25.33s.2.19.34.25c.13.06.28.09.43.09s.3-.03.43-.09zM16 5V4h-5v1h5zm0 2V6h-5v1h5zM7.62 8.83l-1.38-.88c-.41 0-.79.11-1.14.32-.35.22-.62.5-.81.85-.19.34-.29.7-.29 1.07v1.25l.2.05c.13.04.31.09.55.14.24.06.51.12.8.17.29.06.62.1 1 .14.37.04.73.06 1.07.06s.69-.02 1.07-.06.7-.09.98-.14c.27-.05.54-.1.82-.17.27-.06.45-.11.54-.13.09-.03.16-.05.21-.06v-1.25c0-.36-.1-.72-.31-1.07s-.49-.64-.84-.86-.72-.33-1.11-.33zM16 9V8h-3v1h3zm0 2v-1h-3v1h3zm0 3v-1H4v1h12zm0 2v-1H4v1h12z'; + break; + case 'id': + path = 'M18 16H2V4h16v12zM7.05 8.53c.13-.07.24-.15.33-.24.09-.1.17-.21.24-.34.07-.14.13-.26.17-.37s.07-.22.1-.34L7.95 7c0-.04.01-.07.01-.09.05-.32.03-.61-.04-.9-.08-.28-.23-.52-.46-.72C7.23 5.1 6.95 5 6.6 5c-.2 0-.39.04-.56.11-.17.08-.31.18-.41.3-.11.13-.2.27-.27.44-.07.16-.11.33-.12.51s0 .36.01.55l.02.09c.01.06.03.15.06.25s.06.21.1.33.1.25.17.37c.08.12.16.23.25.33s.2.19.34.25c.13.06.28.09.43.09s.3-.03.43-.09zM17 9V5h-5v4h5zm-10.38.83l-1.38-.88c-.41 0-.79.11-1.14.32-.35.22-.62.5-.81.85-.19.34-.29.7-.29 1.07v1.25l.2.05c.13.04.31.09.55.14.24.06.51.12.8.17.29.06.62.1 1 .14.37.04.73.06 1.07.06s.69-.02 1.07-.06.7-.09.98-.14c.27-.05.54-.1.82-.17.27-.06.45-.11.54-.13.09-.03.16-.05.21-.06v-1.25c0-.36-.1-.72-.31-1.07s-.49-.64-.84-.86-.72-.33-1.11-.33zM17 11v-1h-5v1h5zm0 2v-1h-5v1h5zm0 2v-1H3v1h14z'; + break; + case 'image-crop': + path = 'M19 12v3h-4v4h-3v-4H4V7H0V4h4V0h3v4h7l3-3 1 1-3 3v7h4zm-8-5H7v4zm-3 5h4V8z'; + break; + case 'image-filter': + path = 'M14 5.87c0-2.2-1.79-4-4-4s-4 1.8-4 4c0 2.21 1.79 4 4 4s4-1.79 4-4zM3.24 10.66c-1.92 1.1-2.57 3.55-1.47 5.46 1.11 1.92 3.55 2.57 5.47 1.47 1.91-1.11 2.57-3.55 1.46-5.47-1.1-1.91-3.55-2.56-5.46-1.46zm9.52 6.93c1.92 1.1 4.36.45 5.47-1.46 1.1-1.92.45-4.36-1.47-5.47-1.91-1.1-4.36-.45-5.46 1.46-1.11 1.92-.45 4.36 1.46 5.47z'; + break; + case 'image-flip-horizontal': + path = 'M19 3v14h-8v3H9v-3H1V3h8V0h2v3h8zm-8.5 14V3h-1v14h1zM7 6.5L3 10l4 3.5v-7zM17 10l-4-3.5v7z'; + break; + case 'image-flip-vertical': + path = 'M20 9v2h-3v8H3v-8H0V9h3V1h14v8h3zM6.5 7h7L10 3zM17 9.5H3v1h14v-1zM13.5 13h-7l3.5 4z'; + break; + case 'image-rotate-left': + path = 'M7 5H5.05c0-1.74.85-2.9 2.95-2.9V0C4.85 0 2.96 2.11 2.96 5H1.18L3.8 8.39zm13-4v14h-5v5H1V10h9V1h10zm-2 2h-6v7h3v3h3V3zm-5 9H3v6h10v-6z'; + break; + case 'image-rotate-right': + path = 'M15.95 5H14l3.2 3.39L19.82 5h-1.78c0-2.89-1.89-5-5.04-5v2.1c2.1 0 2.95 1.16 2.95 2.9zM1 1h10v9h9v10H6v-5H1V1zm2 2v10h3v-3h3V3H3zm5 9v6h10v-6H8z'; + break; + case 'image-rotate': + path = 'M10.25 1.02c5.1 0 8.75 4.04 8.75 9s-3.65 9-8.75 9c-3.2 0-6.02-1.59-7.68-3.99l2.59-1.52c1.1 1.5 2.86 2.51 4.84 2.51 3.3 0 6-2.79 6-6s-2.7-6-6-6c-1.97 0-3.72 1-4.82 2.49L7 8.02l-6 2v-7L2.89 4.6c1.69-2.17 4.36-3.58 7.36-3.58z'; + break; + case 'images-alt': + path = 'M4 15v-3H2V2h12v3h2v3h2v10H6v-3H4zm7-12c-1.1 0-2 .9-2 2h4c0-1.1-.89-2-2-2zm-7 8V6H3v5h1zm7-3h4c0-1.1-.89-2-2-2-1.1 0-2 .9-2 2zm-5 6V9H5v5h1zm9-1c1.1 0 2-.89 2-2 0-1.1-.9-2-2-2s-2 .9-2 2c0 1.11.9 2 2 2zm2 4v-2c-5 0-5-3-10-3v5h10z'; + break; + case 'images-alt2': + path = 'M5 3h14v11h-2v2h-2v2H1V7h2V5h2V3zm13 10V4H6v9h12zm-3-4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm1 6v-1H5V6H4v9h12zM7 6l10 6H7V6zm7 11v-1H3V8H2v9h12z'; + break; + case 'index-card': + path = 'M1 3.17V18h18V4H8v-.83c0-.32-.12-.6-.35-.83S7.14 2 6.82 2H2.18c-.33 0-.6.11-.83.34-.24.23-.35.51-.35.83zM10 6v2H3V6h7zm7 0v10h-5V6h5zm-7 4v2H3v-2h7zm0 4v2H3v-2h7z'; + break; + case 'info-outline': + path = 'M9 15h2V9H9v6zm1-10c-.5 0-1 .5-1 1s.5 1 1 1 1-.5 1-1-.5-1-1-1zm0-4c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9zm0 16c-3.9 0-7-3.1-7-7s3.1-7 7-7 7 3.1 7 7-3.1 7-7 7z'; + break; + case 'info': + path = 'M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm1 4c0-.55-.45-1-1-1s-1 .45-1 1 .45 1 1 1 1-.45 1-1zm0 9V9H9v6h2z'; + break; + case 'insert-after': + path = 'M9 12h2v-2h2V8h-2V6H9v2H7v2h2v2zm1 4c3.9 0 7-3.1 7-7s-3.1-7-7-7-7 3.1-7 7 3.1 7 7 7zm0-12c2.8 0 5 2.2 5 5s-2.2 5-5 5-5-2.2-5-5 2.2-5 5-5zM3 19h14v-2H3v2z'; + break; + case 'insert-before': + path = 'M11 8H9v2H7v2h2v2h2v-2h2v-2h-2V8zm-1-4c-3.9 0-7 3.1-7 7s3.1 7 7 7 7-3.1 7-7-3.1-7-7-7zm0 12c-2.8 0-5-2.2-5-5s2.2-5 5-5 5 2.2 5 5-2.2 5-5 5zM3 1v2h14V1H3z'; + break; + case 'insert': + path = 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9zm0 16c-3.9 0-7-3.1-7-7s3.1-7 7-7 7 3.1 7 7-3.1 7-7 7zm1-11H9v3H6v2h3v3h2v-3h3V9h-3V6z'; + break; + case 'instagram': + path = 'M12.67 10A2.67 2.67 0 1 0 10 12.67 2.68 2.68 0 0 0 12.67 10zm1.43 0A4.1 4.1 0 1 1 10 5.9a4.09 4.09 0 0 1 4.1 4.1zm1.13-4.27a1 1 0 1 1-1-1 1 1 0 0 1 1 1zM10 3.44c-1.17 0-3.67-.1-4.72.32a2.67 2.67 0 0 0-1.52 1.52c-.42 1-.32 3.55-.32 4.72s-.1 3.67.32 4.72a2.74 2.74 0 0 0 1.52 1.52c1 .42 3.55.32 4.72.32s3.67.1 4.72-.32a2.83 2.83 0 0 0 1.52-1.52c.42-1.05.32-3.55.32-4.72s.1-3.67-.32-4.72a2.74 2.74 0 0 0-1.52-1.52c-1.05-.42-3.55-.32-4.72-.32zM18 10c0 1.1 0 2.2-.05 3.3a4.84 4.84 0 0 1-1.29 3.36A4.8 4.8 0 0 1 13.3 18H6.7a4.84 4.84 0 0 1-3.36-1.29 4.84 4.84 0 0 1-1.29-3.41C2 12.2 2 11.1 2 10V6.7a4.84 4.84 0 0 1 1.34-3.36A4.8 4.8 0 0 1 6.7 2.05C7.8 2 8.9 2 10 2h3.3a4.84 4.84 0 0 1 3.36 1.29A4.8 4.8 0 0 1 18 6.7V10z'; + break; + case 'keyboard-hide': + path = 'M18,0 L2,0 C0.9,0 0.01,0.9 0.01,2 L0,12 C0,13.1 0.9,14 2,14 L18,14 C19.1,14 20,13.1 20,12 L20,2 C20,0.9 19.1,0 18,0 Z M18,12 L2,12 L2,2 L18,2 L18,12 Z M9,3 L11,3 L11,5 L9,5 L9,3 Z M9,6 L11,6 L11,8 L9,8 L9,6 Z M6,3 L8,3 L8,5 L6,5 L6,3 Z M6,6 L8,6 L8,8 L6,8 L6,6 Z M3,6 L5,6 L5,8 L3,8 L3,6 Z M3,3 L5,3 L5,5 L3,5 L3,3 Z M6,9 L14,9 L14,11 L6,11 L6,9 Z M12,6 L14,6 L14,8 L12,8 L12,6 Z M12,3 L14,3 L14,5 L12,5 L12,3 Z M15,6 L17,6 L17,8 L15,8 L15,6 Z M15,3 L17,3 L17,5 L15,5 L15,3 Z M10,20 L14,16 L6,16 L10,20 Z'; + break; + case 'laptop': + path = 'M3 3h14c.6 0 1 .4 1 1v10c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1V4c0-.6.4-1 1-1zm13 2H4v8h12V5zm-3 1H5v4zm6 11v-1H1v1c0 .6.5 1 1.1 1h15.8c.6 0 1.1-.4 1.1-1z'; + break; + case 'layout': + path = 'M2 2h5v11H2V2zm6 0h5v5H8V2zm6 0h4v16h-4V2zM8 8h5v5H8V8zm-6 6h11v4H2v-4z'; + break; + case 'leftright': + path = 'M3 10.03L9 6v8zM11 6l6 4.03L11 14V6z'; + break; + case 'lightbulb': + path = 'M10 1c3.11 0 5.63 2.52 5.63 5.62 0 1.84-2.03 4.58-2.03 4.58-.33.44-.6 1.25-.6 1.8v1c0 .55-.45 1-1 1H8c-.55 0-1-.45-1-1v-1c0-.55-.27-1.36-.6-1.8 0 0-2.02-2.74-2.02-4.58C4.38 3.52 6.89 1 10 1zM7 16.87V16h6v.87c0 .62-.13 1.13-.75 1.13H12c0 .62-.4 1-1.02 1h-2c-.61 0-.98-.38-.98-1h-.25c-.62 0-.75-.51-.75-1.13z'; + break; + case 'list-view': + path = 'M2 19h16c.55 0 1-.45 1-1V2c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v16c0 .55.45 1 1 1zM4 3c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v2H6V3h11zM4 7c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v2H6V7h11zM4 11c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v2H6v-2h11zM4 15c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm13 0v2H6v-2h11z'; + break; + case 'location-alt': + path = 'M13 13.14l1.17-5.94c.79-.43 1.33-1.25 1.33-2.2 0-1.38-1.12-2.5-2.5-2.5S10.5 3.62 10.5 5c0 .95.54 1.77 1.33 2.2zm0-9.64c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm1.72 4.8L18 6.97v9L13.12 18 7 15.97l-5 2v-9l5-2 4.27 1.41 1.73 7.3z'; + break; + case 'location': + path = 'M10 2C6.69 2 4 4.69 4 8c0 2.02 1.17 3.71 2.53 4.89.43.37 1.18.96 1.85 1.83.74.97 1.41 2.01 1.62 2.71.21-.7.88-1.74 1.62-2.71.67-.87 1.42-1.46 1.85-1.83C14.83 11.71 16 10.02 16 8c0-3.31-2.69-6-6-6zm0 2.56c1.9 0 3.44 1.54 3.44 3.44S11.9 11.44 10 11.44 6.56 9.9 6.56 8 8.1 4.56 10 4.56z'; + break; + case 'lock': + path = 'M14 9h1c.55 0 1 .45 1 1v7c0 .55-.45 1-1 1H5c-.55 0-1-.45-1-1v-7c0-.55.45-1 1-1h1V6c0-2.21 1.79-4 4-4s4 1.79 4 4v3zm-2 0V6c0-1.1-.9-2-2-2s-2 .9-2 2v3h4zm-1 7l-.36-2.15c.51-.24.86-.75.86-1.35 0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5c0 .6.35 1.11.86 1.35L9 16h2z'; + break; + case 'marker': + path = 'M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm0 13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z'; + break; + case 'media-archive': + path = 'M12 2l4 4v12H4V2h8zm0 4h3l-3-3v3zM8 3.5v2l1.8-1zM11 5L9.2 6 11 7V5zM8 6.5v2l1.8-1zM11 8L9.2 9l1.8 1V8zM8 9.5v2l1.8-1zm3 1.5l-1.8 1 1.8 1v-2zm-1.5 6c.83 0 1.62-.72 1.5-1.63-.05-.38-.49-1.61-.49-1.61l-1.99-1.1s-.45 1.95-.52 2.71c-.07.77.67 1.63 1.5 1.63zm0-2.39c.42 0 .76.34.76.76 0 .43-.34.77-.76.77s-.76-.34-.76-.77c0-.42.34-.76.76-.76z'; + break; + case 'media-audio': + path = 'M12 2l4 4v12H4V2h8zm0 4h3l-3-3v3zm1 7.26V8.09c0-.11-.04-.21-.12-.29-.07-.08-.16-.11-.27-.1 0 0-3.97.71-4.25.78C8.07 8.54 8 8.8 8 9v3.37c-.2-.09-.42-.07-.6-.07-.38 0-.7.13-.96.39-.26.27-.4.58-.4.96 0 .37.14.69.4.95.26.27.58.4.96.4.34 0 .7-.04.96-.26.26-.23.64-.65.64-1.12V10.3l3-.6V12c-.67-.2-1.17.04-1.44.31-.26.26-.39.58-.39.95 0 .38.13.69.39.96.27.26.71.39 1.08.39.38 0 .7-.13.96-.39.26-.27.4-.58.4-.96z'; + break; + case 'media-code': + path = 'M12 2l4 4v12H4V2h8zM9 13l-2-2 2-2-1-1-3 3 3 3zm3 1l3-3-3-3-1 1 2 2-2 2z'; + break; + case 'media-default': + path = 'M12 2l4 4v12H4V2h8zm0 4h3l-3-3v3z'; + break; + case 'media-document': + path = 'M12 2l4 4v12H4V2h8zM5 3v1h6V3H5zm7 3h3l-3-3v3zM5 5v1h6V5H5zm10 3V7H5v1h10zM5 9v1h4V9H5zm10 3V9h-5v3h5zM5 11v1h4v-1H5zm10 3v-1H5v1h10zm-3 2v-1H5v1h7z'; + break; + case 'media-interactive': + path = 'M12 2l4 4v12H4V2h8zm0 4h3l-3-3v3zm2 8V8H6v6h3l-1 2h1l1-2 1 2h1l-1-2h3zm-6-3c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm5-2v2h-3V9h3zm0 3v1H7v-1h6z'; + break; + case 'media-spreadsheet': + path = 'M12 2l4 4v12H4V2h8zm-1 4V3H5v3h6zM8 8V7H5v1h3zm3 0V7H9v1h2zm4 0V7h-3v1h3zm-7 2V9H5v1h3zm3 0V9H9v1h2zm4 0V9h-3v1h3zm-7 2v-1H5v1h3zm3 0v-1H9v1h2zm4 0v-1h-3v1h3zm-7 2v-1H5v1h3zm3 0v-1H9v1h2zm4 0v-1h-3v1h3zm-7 2v-1H5v1h3zm3 0v-1H9v1h2z'; + break; + case 'media-text': + path = 'M12 2l4 4v12H4V2h8zM5 3v1h6V3H5zm7 3h3l-3-3v3zM5 5v1h6V5H5zm10 3V7H5v1h10zm0 2V9H5v1h10zm0 2v-1H5v1h10zm-4 2v-1H5v1h6z'; + break; + case 'media-video': + path = 'M12 2l4 4v12H4V2h8zm0 4h3l-3-3v3zm-1 8v-3c0-.27-.1-.51-.29-.71-.2-.19-.44-.29-.71-.29H7c-.27 0-.51.1-.71.29-.19.2-.29.44-.29.71v3c0 .27.1.51.29.71.2.19.44.29.71.29h3c.27 0 .51-.1.71-.29.19-.2.29-.44.29-.71zm3 1v-5l-2 2v1z'; + break; + case 'megaphone': + path = 'M18.15 5.94c.46 1.62.38 3.22-.02 4.48-.42 1.28-1.26 2.18-2.3 2.48-.16.06-.26.06-.4.06-.06.02-.12.02-.18.02-.06.02-.14.02-.22.02h-6.8l2.22 5.5c.02.14-.06.26-.14.34-.08.1-.24.16-.34.16H6.95c-.1 0-.26-.06-.34-.16-.08-.08-.16-.2-.14-.34l-1-5.5H4.25l-.02-.02c-.5.06-1.08-.18-1.54-.62s-.88-1.08-1.06-1.88c-.24-.8-.2-1.56-.02-2.2.18-.62.58-1.08 1.06-1.3l.02-.02 9-5.4c.1-.06.18-.1.24-.16.06-.04.14-.08.24-.12.16-.08.28-.12.5-.18 1.04-.3 2.24.1 3.22.98s1.84 2.24 2.26 3.86zm-2.58 5.98h-.02c.4-.1.74-.34 1.04-.7.58-.7.86-1.76.86-3.04 0-.64-.1-1.3-.28-1.98-.34-1.36-1.02-2.5-1.78-3.24s-1.68-1.1-2.46-.88c-.82.22-1.4.96-1.7 2-.32 1.04-.28 2.36.06 3.72.38 1.36 1 2.5 1.8 3.24.78.74 1.62 1.1 2.48.88zm-2.54-7.08c.22-.04.42-.02.62.04.38.16.76.48 1.02 1s.42 1.2.42 1.78c0 .3-.04.56-.12.8-.18.48-.44.84-.86.94-.34.1-.8-.06-1.14-.4s-.64-.86-.78-1.5c-.18-.62-.12-1.24.02-1.72s.48-.84.82-.94z'; + break; + case 'menu-alt': + path = 'M3 4h14v2H3V4zm0 5h14v2H3V9zm0 5h14v2H3v-2z'; + break; + case 'menu': + path = 'M17 7V5H3v2h14zm0 4V9H3v2h14zm0 4v-2H3v2h14z'; + break; + case 'microphone': + path = 'M12 9V3c0-1.1-.89-2-2-2-1.12 0-2 .94-2 2v6c0 1.1.9 2 2 2 1.13 0 2-.94 2-2zm4 0c0 2.97-2.16 5.43-5 5.91V17h2c.56 0 1 .45 1 1s-.44 1-1 1H7c-.55 0-1-.45-1-1s.45-1 1-1h2v-2.09C6.17 14.43 4 11.97 4 9c0-.55.45-1 1-1 .56 0 1 .45 1 1 0 2.21 1.8 4 4 4 2.21 0 4-1.79 4-4 0-.55.45-1 1-1 .56 0 1 .45 1 1z'; + break; + case 'migrate': + path = 'M4 6h6V4H2v12.01h8V14H4V6zm2 2h6V5l6 5-6 5v-3H6V8z'; + break; + case 'minus': + path = 'M4 9h12v2H4V9z'; + break; + case 'money': + path = 'M0 3h20v12h-.75c0-1.79-1.46-3.25-3.25-3.25-1.31 0-2.42.79-2.94 1.91-.25-.1-.52-.16-.81-.16-.98 0-1.8.63-2.11 1.5H0V3zm8.37 3.11c-.06.15-.1.31-.11.47s-.01.33.01.5l.02.08c.01.06.02.14.05.23.02.1.06.2.1.31.03.11.09.22.15.33.07.12.15.22.23.31s.18.17.31.23c.12.06.25.09.4.09.14 0 .27-.03.39-.09s.22-.14.3-.22c.09-.09.16-.2.22-.32.07-.12.12-.23.16-.33s.07-.2.09-.31c.03-.11.04-.18.05-.22s.01-.07.01-.09c.05-.29.03-.56-.04-.82s-.21-.48-.41-.66c-.21-.18-.47-.27-.79-.27-.19 0-.36.03-.52.1-.15.07-.28.16-.38.28-.09.11-.17.25-.24.4zm4.48 6.04v-1.14c0-.33-.1-.66-.29-.98s-.45-.59-.77-.79c-.32-.21-.66-.31-1.02-.31l-1.24.84-1.28-.82c-.37 0-.72.1-1.04.3-.31.2-.56.46-.74.77-.18.32-.27.65-.27.99v1.14l.18.05c.12.04.29.08.51.14.23.05.47.1.74.15.26.05.57.09.91.13.34.03.67.05.99.05.3 0 .63-.02.98-.05.34-.04.64-.08.89-.13.25-.04.5-.1.76-.16l.5-.12c.08-.02.14-.04.19-.06zm3.15.1c1.52 0 2.75 1.23 2.75 2.75s-1.23 2.75-2.75 2.75c-.73 0-1.38-.3-1.87-.77.23-.35.37-.78.37-1.23 0-.77-.39-1.46-.99-1.86.43-.96 1.37-1.64 2.49-1.64zm-5.5 3.5c0-.96.79-1.75 1.75-1.75s1.75.79 1.75 1.75-.79 1.75-1.75 1.75-1.75-.79-1.75-1.75z'; + break; + case 'move': + path = 'M19 10l-4 4v-3h-4v4h3l-4 4-4-4h3v-4H5v3l-4-4 4-4v3h4V5H6l4-4 4 4h-3v4h4V6z'; + break; + case 'nametag': + path = 'M12 5V2c0-.55-.45-1-1-1H9c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1zm-2-3c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm8 13V7c0-1.1-.9-2-2-2h-3v.33C13 6.25 12.25 7 11.33 7H8.67C7.75 7 7 6.25 7 5.33V5H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2zm-1-6v6H3V9h14zm-8 2c0-.55-.22-1-.5-1s-.5.45-.5 1 .22 1 .5 1 .5-.45.5-1zm3 0c0-.55-.22-1-.5-1s-.5.45-.5 1 .22 1 .5 1 .5-.45.5-1zm-5.96 1.21c.92.48 2.34.79 3.96.79s3.04-.31 3.96-.79c-.21 1-1.89 1.79-3.96 1.79s-3.75-.79-3.96-1.79z'; + break; + case 'networking': + path = 'M18 13h1c.55 0 1 .45 1 1.01v2.98c0 .56-.45 1.01-1 1.01h-4c-.55 0-1-.45-1-1.01v-2.98c0-.56.45-1.01 1-1.01h1v-2h-5v2h1c.55 0 1 .45 1 1.01v2.98c0 .56-.45 1.01-1 1.01H8c-.55 0-1-.45-1-1.01v-2.98c0-.56.45-1.01 1-1.01h1v-2H4v2h1c.55 0 1 .45 1 1.01v2.98C6 17.55 5.55 18 5 18H1c-.55 0-1-.45-1-1.01v-2.98C0 13.45.45 13 1 13h1v-2c0-1.1.9-2 2-2h5V7H8c-.55 0-1-.45-1-1.01V3.01C7 2.45 7.45 2 8 2h4c.55 0 1 .45 1 1.01v2.98C13 6.55 12.55 7 12 7h-1v2h5c1.1 0 2 .9 2 2v2z'; + break; + case 'no-alt': + path = 'M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z'; + break; + case 'no': + path = 'M12.12 10l3.53 3.53-2.12 2.12L10 12.12l-3.54 3.54-2.12-2.12L7.88 10 4.34 6.46l2.12-2.12L10 7.88l3.54-3.53 2.12 2.12z'; + break; + case 'palmtree': + path = 'M8.58 2.39c.32 0 .59.05.81.14 1.25.55 1.69 2.24 1.7 3.97.59-.82 2.15-2.29 3.41-2.29s2.94.73 3.53 3.55c-1.13-.65-2.42-.94-3.65-.94-1.26 0-2.45.32-3.29.89.4-.11.86-.16 1.33-.16 1.39 0 2.9.45 3.4 1.31.68 1.16.47 3.38-.76 4.14-.14-2.1-1.69-4.12-3.47-4.12-.44 0-.88.12-1.33.38C8 10.62 7 14.56 7 19H2c0-5.53 4.21-9.65 7.68-10.79-.56-.09-1.17-.15-1.82-.15C6.1 8.06 4.05 8.5 2 10c.76-2.96 2.78-4.1 4.69-4.1 1.25 0 2.45.5 3.2 1.29-.66-2.24-2.49-2.86-4.08-2.86-.8 0-1.55.16-2.05.35.91-1.29 3.31-2.29 4.82-2.29zM13 11.5c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5.67 1.5 1.5 1.5 1.5-.67 1.5-1.5z'; + break; + case 'paperclip': + path = 'M17.05 2.7c1.93 1.94 1.93 5.13 0 7.07L10 16.84c-1.88 1.89-4.91 1.93-6.86.15-.06-.05-.13-.09-.19-.15-1.93-1.94-1.93-5.12 0-7.07l4.94-4.95c.91-.92 2.28-1.1 3.39-.58.3.15.59.33.83.58 1.17 1.17 1.17 3.07 0 4.24l-4.93 4.95c-.39.39-1.02.39-1.41 0s-.39-1.02 0-1.41l4.93-4.95c.39-.39.39-1.02 0-1.41-.38-.39-1.02-.39-1.4 0l-4.94 4.95c-.91.92-1.1 2.29-.57 3.4.14.3.32.59.57.84s.54.43.84.57c1.11.53 2.47.35 3.39-.57l7.05-7.07c1.16-1.17 1.16-3.08 0-4.25-.56-.55-1.28-.83-2-.86-.08.01-.16.01-.24 0-.22-.03-.43-.11-.6-.27-.39-.4-.38-1.05.02-1.45.16-.16.36-.24.56-.28.14-.02.27-.01.4.02 1.19.06 2.36.52 3.27 1.43z'; + break; + case 'performance': + path = 'M3.76 17.01h12.48C17.34 15.63 18 13.9 18 12c0-4.41-3.58-8-8-8s-8 3.59-8 8c0 1.9.66 3.63 1.76 5.01zM9 6c0-.55.45-1 1-1s1 .45 1 1c0 .56-.45 1-1 1s-1-.44-1-1zM4 8c0-.55.45-1 1-1s1 .45 1 1c0 .56-.45 1-1 1s-1-.44-1-1zm4.52 3.4c.84-.83 6.51-3.5 6.51-3.5s-2.66 5.68-3.49 6.51c-.84.84-2.18.84-3.02 0-.83-.83-.83-2.18 0-3.01zM3 13c0-.55.45-1 1-1s1 .45 1 1c0 .56-.45 1-1 1s-1-.44-1-1zm6 0c0-.55.45-1 1-1s1 .45 1 1c0 .56-.45 1-1 1s-1-.44-1-1zm6 0c0-.55.45-1 1-1s1 .45 1 1c0 .56-.45 1-1 1s-1-.44-1-1z'; + break; + case 'phone': + path = 'M12.06 6l-.21-.2c-.52-.54-.43-.79.08-1.3l2.72-2.75c.81-.82.96-1.21 1.73-.48l.21.2zm.53.45l4.4-4.4c.7.94 2.34 3.47 1.53 5.34-.73 1.67-1.09 1.75-2 3-1.85 2.11-4.18 4.37-6 6.07-1.26.91-1.31 1.33-3 2-1.8.71-4.4-.89-5.38-1.56l4.4-4.4 1.18 1.62c.34.46 1.2-.06 1.8-.66 1.04-1.05 3.18-3.18 4-4.07.59-.59 1.12-1.45.66-1.8zM1.57 16.5l-.21-.21c-.68-.74-.29-.9.52-1.7l2.74-2.72c.51-.49.75-.6 1.27-.11l.2.21z'; + break; + case 'playlist-audio': + path = 'M17 3V1H2v2h15zm0 4V5H2v2h15zm-7 4V9H2v2h8zm7.45-1.96l-6 1.12c-.16.02-.19.03-.29.13-.11.09-.16.22-.16.37v4.59c-.29-.13-.66-.14-.93-.14-.54 0-1 .19-1.38.57s-.56.84-.56 1.38c0 .53.18.99.56 1.37s.84.57 1.38.57c.49 0 .92-.16 1.29-.48s.59-.71.65-1.19v-4.95L17 11.27v3.48c-.29-.13-.56-.19-.83-.19-.54 0-1.11.19-1.49.57-.38.37-.57.83-.57 1.37s.19.99.57 1.37.84.57 1.38.57c.53 0 .99-.19 1.37-.57s.57-.83.57-1.37V9.6c0-.16-.05-.3-.16-.41-.11-.12-.24-.17-.39-.15zM8 15v-2H2v2h6zm-2 4v-2H2v2h4z'; + break; + case 'playlist-video': + path = 'M17 3V1H2v2h15zm0 4V5H2v2h15zM6 11V9H2v2h4zm2-2h9c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H8c-.55 0-1-.45-1-1v-8c0-.55.45-1 1-1zm3 7l3.33-2L11 12v4zm-5-1v-2H2v2h4zm0 4v-2H2v2h4z'; + break; + case 'plus-alt': + path = 'M15.8 4.2c3.2 3.21 3.2 8.39 0 11.6-3.21 3.2-8.39 3.2-11.6 0C1 12.59 1 7.41 4.2 4.2 7.41 1 12.59 1 15.8 4.2zm-4.3 11.3v-4h4v-3h-4v-4h-3v4h-4v3h4v4h3z'; + break; + case 'plus-light': + path = 'M17 9v2h-6v6H9v-6H3V9h6V3h2v6h6z'; + break; + case 'plus': + path = 'M17 7v3h-5v5H9v-5H4V7h5V2h3v5h5z'; + break; + case 'portfolio': + path = 'M4 5H.78c-.37 0-.74.32-.69.84l1.56 9.99S3.5 8.47 3.86 6.7c.11-.53.61-.7.98-.7H10s-.7-2.08-.77-2.31C9.11 3.25 8.89 3 8.45 3H5.14c-.36 0-.7.23-.8.64C4.25 4.04 4 5 4 5zm4.88 0h-4s.42-1 .87-1h2.13c.48 0 1 1 1 1zM2.67 16.25c-.31.47-.76.75-1.26.75h15.73c.54 0 .92-.31 1.03-.83.44-2.19 1.68-8.44 1.68-8.44.07-.5-.3-.73-.62-.73H16V5.53c0-.16-.26-.53-.66-.53h-3.76c-.52 0-.87.58-.87.58L10 7H5.59c-.32 0-.63.19-.69.5 0 0-1.59 6.7-1.72 7.33-.07.37-.22.99-.51 1.42zM15.38 7H11s.58-1 1.13-1h2.29c.71 0 .96 1 .96 1z'; + break; + case 'post-status': + path = 'M14 6c0 1.86-1.28 3.41-3 3.86V16c0 1-2 2-2 2V9.86c-1.72-.45-3-2-3-3.86 0-2.21 1.79-4 4-4s4 1.79 4 4zM8 5c0 .55.45 1 1 1s1-.45 1-1-.45-1-1-1-1 .45-1 1z'; + break; + case 'pressthis': + path = 'M14.76 1C16.55 1 18 2.46 18 4.25c0 1.78-1.45 3.24-3.24 3.24-.23 0-.47-.03-.7-.08L13 8.47V19H2V4h9.54c.13-2 1.52-3 3.22-3zm0 5.49C16 6.49 17 5.48 17 4.25 17 3.01 16 2 14.76 2s-2.24 1.01-2.24 2.25c0 .37.1.72.27 1.03L9.57 8.5c-.28.28-1.77 2.22-1.5 2.49.02.03.06.04.1.04.49 0 2.14-1.28 2.39-1.53l3.24-3.24c.29.14.61.23.96.23z'; + break; + case 'products': + path = 'M17 8h1v11H2V8h1V6c0-2.76 2.24-5 5-5 .71 0 1.39.15 2 .42.61-.27 1.29-.42 2-.42 2.76 0 5 2.24 5 5v2zM5 6v2h2V6c0-1.13.39-2.16 1.02-3H8C6.35 3 5 4.35 5 6zm10 2V6c0-1.65-1.35-3-3-3h-.02c.63.84 1.02 1.87 1.02 3v2h2zm-5-4.22C9.39 4.33 9 5.12 9 6v2h2V6c0-.88-.39-1.67-1-2.22z'; + break; + case 'randomize': + path = 'M18 6.01L14 9V7h-4l-5 8H2v-2h2l5-8h5V3zM2 5h3l1.15 2.17-1.12 1.8L4 7H2V5zm16 9.01L14 17v-2H9l-1.15-2.17 1.12-1.8L10 13h4v-2z'; + break; + case 'redo': + path = 'M8 5h5V2l6 4-6 4V7H8c-2.2 0-4 1.8-4 4s1.8 4 4 4h5v2H8c-3.3 0-6-2.7-6-6s2.7-6 6-6z'; + break; + case 'rest-api': + path = 'M3 4h2v12H3z'; + break; + case 'rss': + path = 'M14.92 18H18C18 9.32 10.82 2.25 2 2.25v3.02c7.12 0 12.92 5.71 12.92 12.73zm-5.44 0h3.08C12.56 12.27 7.82 7.6 2 7.6v3.02c2 0 3.87.77 5.29 2.16C8.7 14.17 9.48 16.03 9.48 18zm-5.35-.02c1.17 0 2.13-.93 2.13-2.09 0-1.15-.96-2.09-2.13-2.09-1.18 0-2.13.94-2.13 2.09 0 1.16.95 2.09 2.13 2.09z'; + break; + case 'saved': + path = 'M15.3 5.3l-6.8 6.8-2.8-2.8-1.4 1.4 4.2 4.2 8.2-8.2'; + break; + case 'schedule': + path = 'M2 2h16v4H2V2zm0 10V8h4v4H2zm6-2V8h4v2H8zm6 3V8h4v5h-4zm-6 5v-6h4v6H8zm-6 0v-4h4v4H2zm12 0v-3h4v3h-4z'; + break; + case 'screenoptions': + path = 'M9 9V3H3v6h6zm8 0V3h-6v6h6zm-8 8v-6H3v6h6zm8 0v-6h-6v6h6z'; + break; + case 'search': + path = 'M12.14 4.18c1.87 1.87 2.11 4.75.72 6.89.12.1.22.21.36.31.2.16.47.36.81.59.34.24.56.39.66.47.42.31.73.57.94.78.32.32.6.65.84 1 .25.35.44.69.59 1.04.14.35.21.68.18 1-.02.32-.14.59-.36.81s-.49.34-.81.36c-.31.02-.65-.04-.99-.19-.35-.14-.7-.34-1.04-.59-.35-.24-.68-.52-1-.84-.21-.21-.47-.52-.77-.93-.1-.13-.25-.35-.47-.66-.22-.32-.4-.57-.56-.78-.16-.2-.29-.35-.44-.5-2.07 1.09-4.69.76-6.44-.98-2.14-2.15-2.14-5.64 0-7.78 2.15-2.15 5.63-2.15 7.78 0zm-1.41 6.36c1.36-1.37 1.36-3.58 0-4.95-1.37-1.37-3.59-1.37-4.95 0-1.37 1.37-1.37 3.58 0 4.95 1.36 1.37 3.58 1.37 4.95 0z'; + break; + case 'share-alt': + path = 'M16.22 5.8c.47.69.29 1.62-.4 2.08-.69.47-1.62.29-2.08-.4-.16-.24-.35-.46-.55-.67-.21-.2-.43-.39-.67-.55s-.5-.3-.77-.41c-.27-.12-.55-.21-.84-.26-.59-.13-1.23-.13-1.82-.01-.29.06-.57.15-.84.27-.27.11-.53.25-.77.41s-.46.35-.66.55c-.21.21-.4.43-.56.67s-.3.5-.41.76c-.01.02-.01.03-.01.04-.1.24-.17.48-.23.72H1V6h2.66c.04-.07.07-.13.12-.2.27-.4.57-.77.91-1.11s.72-.65 1.11-.91c.4-.27.83-.51 1.28-.7s.93-.34 1.41-.43c.99-.21 2.03-.21 3.02 0 .48.09.96.24 1.41.43s.88.43 1.28.7c.39.26.77.57 1.11.91s.64.71.91 1.11zM12.5 10c0-1.38-1.12-2.5-2.5-2.5S7.5 8.62 7.5 10s1.12 2.5 2.5 2.5 2.5-1.12 2.5-2.5zm-8.72 4.2c-.47-.69-.29-1.62.4-2.09.69-.46 1.62-.28 2.08.41.16.24.35.46.55.67.21.2.43.39.67.55s.5.3.77.41c.27.12.55.2.84.26.59.13 1.23.12 1.82 0 .29-.06.57-.14.84-.26.27-.11.53-.25.77-.41s.46-.35.66-.55c.21-.21.4-.44.56-.67.16-.25.3-.5.41-.76.01-.02.01-.03.01-.04.1-.24.17-.48.23-.72H19v3h-2.66c-.04.06-.07.13-.12.2-.27.4-.57.77-.91 1.11s-.72.65-1.11.91c-.4.27-.83.51-1.28.7s-.93.33-1.41.43c-.99.21-2.03.21-3.02 0-.48-.1-.96-.24-1.41-.43s-.88-.43-1.28-.7c-.39-.26-.77-.57-1.11-.91s-.64-.71-.91-1.11z'; + break; + case 'share-alt2': + path = 'M18 8l-5 4V9.01c-2.58.06-4.88.45-7 2.99.29-3.57 2.66-5.66 7-5.94V3zM4 14h11v-2l2-1.6V16H2V5h9.43c-1.83.32-3.31 1-4.41 2H4v7z'; + break; + case 'share': + path = 'M14.5 12c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3c0-.24.03-.46.09-.69l-4.38-2.3c-.55.61-1.33.99-2.21.99-1.66 0-3-1.34-3-3s1.34-3 3-3c.88 0 1.66.39 2.21.99l4.38-2.3c-.06-.23-.09-.45-.09-.69 0-1.66 1.34-3 3-3s3 1.34 3 3-1.34 3-3 3c-.88 0-1.66-.39-2.21-.99l-4.38 2.3c.06.23.09.45.09.69s-.03.46-.09.69l4.38 2.3c.55-.61 1.33-.99 2.21-.99z'; + break; + case 'shield-alt': + path = 'M10 2s3 2 7 2c0 11-7 14-7 14S3 15 3 4c4 0 7-2 7-2z'; + break; + case 'shield': + path = 'M10 2s3 2 7 2c0 11-7 14-7 14S3 15 3 4c4 0 7-2 7-2zm0 8h5s1-1 1-5c0 0-5-1-6-2v7H5c1 4 5 7 5 7v-7z'; + break; + case 'shortcode': + path = 'M6 14H4V6h2V4H2v12h4M7.1 17h2.1l3.7-14h-2.1M14 4v2h2v8h-2v2h4V4'; + break; + case 'slides': + path = 'M5 14V6h10v8H5zm-3-1V7h2v6H2zm4-6v6h8V7H6zm10 0h2v6h-2V7zm-3 2V8H7v1h6zm0 3v-2H7v2h6z'; + break; + case 'smartphone': + path = 'M6 2h8c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1zm7 12V4H7v10h6zM8 5h4l-4 5V5z'; + break; + case 'smiley': + path = 'M7 5.2c1.1 0 2 .89 2 2 0 .37-.11.71-.28 1C8.72 8.2 8 8 7 8s-1.72.2-1.72.2c-.17-.29-.28-.63-.28-1 0-1.11.9-2 2-2zm6 0c1.11 0 2 .89 2 2 0 .37-.11.71-.28 1 0 0-.72-.2-1.72-.2s-1.72.2-1.72.2c-.17-.29-.28-.63-.28-1 0-1.11.89-2 2-2zm-3 13.7c3.72 0 7.03-2.36 8.23-5.88l-1.32-.46C15.9 15.52 13.12 17.5 10 17.5s-5.9-1.98-6.91-4.94l-1.32.46c1.2 3.52 4.51 5.88 8.23 5.88z'; + break; + case 'sort': + path = 'M11 7H1l5 7zm-2 7h10l-5-7z'; + break; + case 'sos': + path = 'M18 10c0-4.42-3.58-8-8-8s-8 3.58-8 8 3.58 8 8 8 8-3.58 8-8zM7.23 3.57L8.72 7.3c-.62.29-1.13.8-1.42 1.42L3.57 7.23c.71-1.64 2.02-2.95 3.66-3.66zm9.2 3.66L12.7 8.72c-.29-.62-.8-1.13-1.42-1.42l1.49-3.73c1.64.71 2.95 2.02 3.66 3.66zM10 12c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm-6.43.77l3.73-1.49c.29.62.8 1.13 1.42 1.42l-1.49 3.73c-1.64-.71-2.95-2.02-3.66-3.66zm9.2 3.66l-1.49-3.73c.62-.29 1.13-.8 1.42-1.42l3.73 1.49c-.71 1.64-2.02 2.95-3.66 3.66z'; + break; + case 'star-empty': + path = 'M10 1L7 7l-6 .75 4.13 4.62L4 19l6-3 6 3-1.12-6.63L19 7.75 13 7zm0 2.24l2.34 4.69 4.65.58-3.18 3.56.87 5.15L10 14.88l-4.68 2.34.87-5.15-3.18-3.56 4.65-.58z'; + break; + case 'star-filled': + path = 'M10 1l3 6 6 .75-4.12 4.62L16 19l-6-3-6 3 1.13-6.63L1 7.75 7 7z'; + break; + case 'star-half': + path = 'M10 1L7 7l-6 .75 4.13 4.62L4 19l6-3 6 3-1.12-6.63L19 7.75 13 7zm0 2.24l2.34 4.69 4.65.58-3.18 3.56.87 5.15L10 14.88V3.24z'; + break; + case 'sticky': + path = 'M5 3.61V1.04l8.99-.01-.01 2.58c-1.22.26-2.16 1.35-2.16 2.67v.5c.01 1.31.93 2.4 2.17 2.66l-.01 2.58h-3.41l-.01 2.57c0 .6-.47 4.41-1.06 4.41-.6 0-1.08-3.81-1.08-4.41v-2.56L5 12.02l.01-2.58c1.23-.25 2.15-1.35 2.15-2.66v-.5c0-1.31-.92-2.41-2.16-2.67z'; + break; + case 'store': + path = 'M1 10c.41.29.96.43 1.5.43.55 0 1.09-.14 1.5-.43.62-.46 1-1.17 1-2 0 .83.37 1.54 1 2 .41.29.96.43 1.5.43.55 0 1.09-.14 1.5-.43.62-.46 1-1.17 1-2 0 .83.37 1.54 1 2 .41.29.96.43 1.51.43.54 0 1.08-.14 1.49-.43.62-.46 1-1.17 1-2 0 .83.37 1.54 1 2 .41.29.96.43 1.5.43.55 0 1.09-.14 1.5-.43.63-.46 1-1.17 1-2V7l-3-7H4L0 7v1c0 .83.37 1.54 1 2zm2 8.99h5v-5h4v5h5v-7c-.37-.05-.72-.22-1-.43-.63-.45-1-.73-1-1.56 0 .83-.38 1.11-1 1.56-.41.3-.95.43-1.49.44-.55 0-1.1-.14-1.51-.44-.63-.45-1-.73-1-1.56 0 .83-.38 1.11-1 1.56-.41.3-.95.43-1.5.44-.54 0-1.09-.14-1.5-.44-.63-.45-1-.73-1-1.57 0 .84-.38 1.12-1 1.57-.29.21-.63.38-1 .44v6.99z'; + break; + case 'table-col-after': + path = 'M14.08 12.864V9.216h3.648V7.424H14.08V3.776h-1.728v3.648H8.64v1.792h3.712v3.648zM0 17.92V0h20.48v17.92H0zM6.4 1.28H1.28v3.84H6.4V1.28zm0 5.12H1.28v3.84H6.4V6.4zm0 5.12H1.28v3.84H6.4v-3.84zM19.2 1.28H7.68v14.08H19.2V1.28z'; + break; + case 'table-col-before': + path = 'M6.4 3.776v3.648H2.752v1.792H6.4v3.648h1.728V9.216h3.712V7.424H8.128V3.776zM0 17.92V0h20.48v17.92H0zM12.8 1.28H1.28v14.08H12.8V1.28zm6.4 0h-5.12v3.84h5.12V1.28zm0 5.12h-5.12v3.84h5.12V6.4zm0 5.12h-5.12v3.84h5.12v-3.84z'; + break; + case 'table-col-delete': + path = 'M6.4 9.98L7.68 8.7v-.256L6.4 7.164V9.98zm6.4-1.532l1.28-1.28V9.92L12.8 8.64v-.192zm7.68 9.472V0H0v17.92h20.48zm-1.28-2.56h-5.12v-1.024l-.256.256-1.024-1.024v1.792H7.68v-1.792l-1.024 1.024-.256-.256v1.024H1.28V1.28H6.4v2.368l.704-.704.576.576V1.216h5.12V3.52l.96-.96.32.32V1.216h5.12V15.36zm-5.76-2.112l-3.136-3.136-3.264 3.264-1.536-1.536 3.264-3.264L5.632 5.44l1.536-1.536 3.136 3.136 3.2-3.2 1.536 1.536-3.2 3.2 3.136 3.136-1.536 1.536z'; + break; + case 'table-row-after': + path = 'M13.824 10.176h-2.88v-2.88H9.536v2.88h-2.88v1.344h2.88v2.88h1.408v-2.88h2.88zM0 17.92V0h20.48v17.92H0zM6.4 1.28H1.28v3.84H6.4V1.28zm6.4 0H7.68v3.84h5.12V1.28zm6.4 0h-5.12v3.84h5.12V1.28zm0 5.056H1.28v9.024H19.2V6.336z'; + break; + case 'table-row-before': + path = 'M6.656 6.464h2.88v2.88h1.408v-2.88h2.88V5.12h-2.88V2.24H9.536v2.88h-2.88zM0 17.92V0h20.48v17.92H0zm7.68-2.56h5.12v-3.84H7.68v3.84zm-6.4 0H6.4v-3.84H1.28v3.84zM19.2 1.28H1.28v9.024H19.2V1.28zm0 10.24h-5.12v3.84h5.12v-3.84z'; + break; + case 'table-row-delete': + path = 'M17.728 11.456L14.592 8.32l3.2-3.2-1.536-1.536-3.2 3.2L9.92 3.648 8.384 5.12l3.2 3.2-3.264 3.264 1.536 1.536 3.264-3.264 3.136 3.136 1.472-1.536zM0 17.92V0h20.48v17.92H0zm19.2-6.4h-.448l-1.28-1.28H19.2V6.4h-1.792l1.28-1.28h.512V1.28H1.28v3.84h6.208l1.28 1.28H1.28v3.84h7.424l-1.28 1.28H1.28v3.84H19.2v-3.84z'; + break; + case 'tablet': + path = 'M4 2h12c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1H4c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1zm11 14V4H5v12h10zM6 5h6l-6 5V5z'; + break; + case 'tag': + path = 'M11 2h7v7L8 19l-7-7zm3 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z'; + break; + case 'tagcloud': + path = 'M11 3v4H1V3h10zm8 0v4h-7V3h7zM7 8v3H1V8h6zm12 0v3H8V8h11zM9 12v2H1v-2h8zm10 0v2h-9v-2h9zM6 15v1H1v-1h5zm5 0v1H7v-1h4zm3 0v1h-2v-1h2zm5 0v1h-4v-1h4z'; + break; + case 'testimonial': + path = 'M4 3h12c.55 0 1.02.2 1.41.59S18 4.45 18 5v7c0 .55-.2 1.02-.59 1.41S16.55 14 16 14h-1l-5 5v-5H4c-.55 0-1.02-.2-1.41-.59S2 12.55 2 12V5c0-.55.2-1.02.59-1.41S3.45 3 4 3zm11 2H4v1h11V5zm1 3H4v1h12V8zm-3 3H4v1h9v-1z'; + break; + case 'text': + path = 'M18 3v2H2V3h16zm-6 4v2H2V7h10zm6 0v2h-4V7h4zM8 11v2H2v-2h6zm10 0v2h-8v-2h8zm-4 4v2H2v-2h12z'; + break; + case 'thumbs-down': + path = 'M7.28 18c-.15.02-.26-.02-.41-.07-.56-.19-.83-.79-.66-1.35.17-.55 1-3.04 1-3.58 0-.53-.75-1-1.35-1h-3c-.6 0-1-.4-1-1s2-7 2-7c.17-.39.55-1 1-1H14v9h-2.14c-.41.41-3.3 4.71-3.58 5.27-.21.41-.6.68-1 .73zM18 12h-2V3h2v9z'; + break; + case 'thumbs-up': + path = 'M12.72 2c.15-.02.26.02.41.07.56.19.83.79.66 1.35-.17.55-1 3.04-1 3.58 0 .53.75 1 1.35 1h3c.6 0 1 .4 1 1s-2 7-2 7c-.17.39-.55 1-1 1H6V8h2.14c.41-.41 3.3-4.71 3.58-5.27.21-.41.6-.68 1-.73zM2 8h2v9H2V8z'; + break; + case 'tickets-alt': + path = 'M20 6.38L18.99 9.2v-.01c-.52-.19-1.03-.16-1.53.08s-.85.62-1.04 1.14-.16 1.03.07 1.53c.24.5.62.84 1.15 1.03v.01l-1.01 2.82-15.06-5.38.99-2.79c.52.19 1.03.16 1.53-.08.5-.23.84-.61 1.03-1.13s.16-1.03-.08-1.53c-.23-.49-.61-.83-1.13-1.02L4.93 1zm-4.97 5.69l1.37-3.76c.12-.31.1-.65-.04-.95s-.39-.53-.7-.65L8.14 3.98c-.64-.23-1.37.12-1.6.74L5.17 8.48c-.24.65.1 1.37.74 1.6l7.52 2.74c.14.05.28.08.43.08.52 0 1-.33 1.17-.83zM7.97 4.45l7.51 2.73c.19.07.34.21.43.39.08.18.09.38.02.57l-1.37 3.76c-.13.38-.58.59-.96.45L6.09 9.61c-.39-.14-.59-.57-.45-.96l1.37-3.76c.1-.29.39-.49.7-.49.09 0 .17.02.26.05zm6.82 12.14c.35.27.75.41 1.2.41H16v3H0v-2.96c.55 0 1.03-.2 1.41-.59.39-.38.59-.86.59-1.41s-.2-1.02-.59-1.41-.86-.59-1.41-.59V10h1.05l-.28.8 2.87 1.02c-.51.16-.89.62-.89 1.18v4c0 .69.56 1.25 1.25 1.25h8c.69 0 1.25-.56 1.25-1.25v-1.75l.83.3c.12.43.36.78.71 1.04zM3.25 17v-4c0-.41.34-.75.75-.75h.83l7.92 2.83V17c0 .41-.34.75-.75.75H4c-.41 0-.75-.34-.75-.75z'; + break; + case 'tickets': + path = 'M20 5.38L18.99 8.2v-.01c-1.04-.37-2.19.18-2.57 1.22-.37 1.04.17 2.19 1.22 2.56v.01l-1.01 2.82L1.57 9.42l.99-2.79c1.04.38 2.19-.17 2.56-1.21s-.17-2.18-1.21-2.55L4.93 0zm-5.45 3.37c.74-2.08-.34-4.37-2.42-5.12-2.08-.74-4.37.35-5.11 2.42-.74 2.08.34 4.38 2.42 5.12 2.07.74 4.37-.35 5.11-2.42zm-2.56-4.74c.89.32 1.57.94 1.97 1.71-.01-.01-.02-.01-.04-.02-.33-.12-.67.09-.78.4-.1.28-.03.57.05.91.04.27.09.62-.06 1.04-.1.29-.33.58-.65 1l-.74 1.01.08-4.08.4.11c.19.04.26-.24.08-.29 0 0-.57-.15-.92-.28-.34-.12-.88-.36-.88-.36-.18-.08-.3.19-.12.27 0 0 .16.08.34.16l.01 1.63L9.2 9.18l.08-4.11c.2.06.4.11.4.11.19.04.26-.23.07-.29 0 0-.56-.15-.91-.28-.07-.02-.14-.05-.22-.08.93-.7 2.19-.94 3.37-.52zM7.4 6.19c.17-.49.44-.92.78-1.27l.04 5c-.94-.95-1.3-2.39-.82-3.73zm4.04 4.75l2.1-2.63c.37-.41.57-.77.69-1.12.05-.12.08-.24.11-.35.09.57.04 1.18-.17 1.77-.45 1.25-1.51 2.1-2.73 2.33zm-.7-3.22l.02 3.22c0 .02 0 .04.01.06-.4 0-.8-.07-1.2-.21-.33-.12-.63-.28-.9-.48zm1.24 6.08l2.1.75c.24.84 1 1.45 1.91 1.45H16v3H0v-2.96c1.1 0 2-.89 2-2 0-1.1-.9-2-2-2V9h1.05l-.28.8 4.28 1.52C4.4 12.03 4 12.97 4 14c0 2.21 1.79 4 4 4s4-1.79 4-4c0-.07-.02-.13-.02-.2zm-6.53-2.33l1.48.53c-.14.04-.15.27.03.28 0 0 .18.02.37.03l.56 1.54-.78 2.36-1.31-3.9c.21-.01.41-.03.41-.03.19-.02.17-.31-.02-.3 0 0-.59.05-.96.05-.07 0-.15 0-.23-.01.13-.2.28-.38.45-.55zM4.4 14c0-.52.12-1.02.32-1.46l1.71 4.7C5.23 16.65 4.4 15.42 4.4 14zm4.19-1.41l1.72.62c.07.17.12.37.12.61 0 .31-.12.66-.28 1.16l-.35 1.2zM11.6 14c0 1.33-.72 2.49-1.79 3.11l1.1-3.18c.06-.17.1-.31.14-.46l.52.19c.02.11.03.22.03.34zm-4.62 3.45l1.08-3.14 1.11 3.03c.01.02.01.04.02.05-.37.13-.77.21-1.19.21-.35 0-.69-.06-1.02-.15z'; + break; + case 'tide': + path = 'M17 7.2V3H3v7.1c2.6-.5 4.5-1.5 6.4-2.6.2-.2.4-.3.6-.5v3c-1.9 1.1-4 2.2-7 2.8V17h14V9.9c-2.6.5-4.4 1.5-6.2 2.6-.3.1-.5.3-.8.4V10c2-1.1 4-2.2 7-2.8z'; + break; + case 'translation': + path = 'M11 7H9.49c-.63 0-1.25.3-1.59.7L7 5H4.13l-2.39 7h1.69l.74-2H7v4H2c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h7c1.1 0 2 .9 2 2v2zM6.51 9H4.49l1-2.93zM10 8h7c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2h-7c-1.1 0-2-.9-2-2v-7c0-1.1.9-2 2-2zm7.25 5v-1.08h-3.17V9.75h-1.16v2.17H9.75V13h1.28c.11.85.56 1.85 1.28 2.62-.87.36-1.89.62-2.31.62-.01.02.22.97.2 1.46.84 0 2.21-.5 3.28-1.15 1.09.65 2.48 1.15 3.34 1.15-.02-.49.2-1.44.2-1.46-.43 0-1.49-.27-2.38-.63.7-.77 1.14-1.77 1.25-2.61h1.36zm-3.81 1.93c-.5-.46-.85-1.13-1.01-1.93h2.09c-.17.8-.51 1.47-1 1.93l-.04.03s-.03-.02-.04-.03z'; + break; + case 'trash': + path = 'M12 4h3c.6 0 1 .4 1 1v1H3V5c0-.6.5-1 1-1h3c.2-1.1 1.3-2 2.5-2s2.3.9 2.5 2zM8 4h3c-.2-.6-.9-1-1.5-1S8.2 3.4 8 4zM4 7h11l-.9 10.1c0 .5-.5.9-1 .9H5.9c-.5 0-.9-.4-1-.9L4 7z'; + break; + case 'twitter': + path = 'M18.94 4.46c-.49.73-1.11 1.38-1.83 1.9.01.15.01.31.01.47 0 4.85-3.69 10.44-10.43 10.44-2.07 0-4-.61-5.63-1.65.29.03.58.05.88.05 1.72 0 3.3-.59 4.55-1.57-1.6-.03-2.95-1.09-3.42-2.55.22.04.45.07.69.07.33 0 .66-.05.96-.13-1.67-.34-2.94-1.82-2.94-3.6v-.04c.5.27 1.06.44 1.66.46-.98-.66-1.63-1.78-1.63-3.06 0-.67.18-1.3.5-1.84 1.81 2.22 4.51 3.68 7.56 3.83-.06-.27-.1-.55-.1-.84 0-2.02 1.65-3.66 3.67-3.66 1.06 0 2.01.44 2.68 1.16.83-.17 1.62-.47 2.33-.89-.28.85-.86 1.57-1.62 2.02.75-.08 1.45-.28 2.11-.57z'; + break; + case 'undo': + path = 'M12 5H7V2L1 6l6 4V7h5c2.2 0 4 1.8 4 4s-1.8 4-4 4H7v2h5c3.3 0 6-2.7 6-6s-2.7-6-6-6z'; + break; + case 'universal-access-alt': + path = 'M19 10c0-4.97-4.03-9-9-9s-9 4.03-9 9 4.03 9 9 9 9-4.03 9-9zm-9-7.4c.83 0 1.5.67 1.5 1.5s-.67 1.51-1.5 1.51c-.82 0-1.5-.68-1.5-1.51s.68-1.5 1.5-1.5zM3.4 7.36c0-.65 6.6-.76 6.6-.76s6.6.11 6.6.76-4.47 1.4-4.47 1.4 1.69 8.14 1.06 8.38c-.62.24-3.19-5.19-3.19-5.19s-2.56 5.43-3.18 5.19c-.63-.24 1.06-8.38 1.06-8.38S3.4 8.01 3.4 7.36z'; + break; + case 'universal-access': + path = 'M10 2.6c.83 0 1.5.67 1.5 1.5s-.67 1.51-1.5 1.51c-.82 0-1.5-.68-1.5-1.51s.68-1.5 1.5-1.5zM3.4 7.36c0-.65 6.6-.76 6.6-.76s6.6.11 6.6.76-4.47 1.4-4.47 1.4 1.69 8.14 1.06 8.38c-.62.24-3.19-5.19-3.19-5.19s-2.56 5.43-3.18 5.19c-.63-.24 1.06-8.38 1.06-8.38S3.4 8.01 3.4 7.36z'; + break; + case 'unlock': + path = 'M12 9V6c0-1.1-.9-2-2-2s-2 .9-2 2H6c0-2.21 1.79-4 4-4s4 1.79 4 4v3h1c.55 0 1 .45 1 1v7c0 .55-.45 1-1 1H5c-.55 0-1-.45-1-1v-7c0-.55.45-1 1-1h7zm-1 7l-.36-2.15c.51-.24.86-.75.86-1.35 0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5c0 .6.35 1.11.86 1.35L9 16h2z'; + break; + case 'update': + path = 'M10.2 3.28c3.53 0 6.43 2.61 6.92 6h2.08l-3.5 4-3.5-4h2.32c-.45-1.97-2.21-3.45-4.32-3.45-1.45 0-2.73.71-3.54 1.78L4.95 5.66C6.23 4.2 8.11 3.28 10.2 3.28zm-.4 13.44c-3.52 0-6.43-2.61-6.92-6H.8l3.5-4c1.17 1.33 2.33 2.67 3.5 4H5.48c.45 1.97 2.21 3.45 4.32 3.45 1.45 0 2.73-.71 3.54-1.78l1.71 1.95c-1.28 1.46-3.15 2.38-5.25 2.38z'; + break; + case 'upload': + path = 'M8 14V8H5l5-6 5 6h-3v6H8zm-2 2v-6H4v8h12.01v-8H14v6H6z'; + break; + case 'vault': + path = 'M18 17V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h14c.55 0 1-.45 1-1zm-1 0H3V3h14v14zM4.75 4h10.5c.41 0 .75.34.75.75V6h-1v3h1v2h-1v3h1v1.25c0 .41-.34.75-.75.75H4.75c-.41 0-.75-.34-.75-.75V4.75c0-.41.34-.75.75-.75zM13 10c0-2.21-1.79-4-4-4s-4 1.79-4 4 1.79 4 4 4 4-1.79 4-4zM9 7l.77 1.15C10.49 8.46 11 9.17 11 10c0 1.1-.9 2-2 2s-2-.9-2-2c0-.83.51-1.54 1.23-1.85z'; + break; + case 'video-alt': + path = 'M8 5c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1 0 .57.49 1 1 1h5c.55 0 1-.45 1-1zm6 5l4-4v10l-4-4v-2zm-1 4V8c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h8c.55 0 1-.45 1-1z'; + break; + case 'video-alt2': + path = 'M12 13V7c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2zm1-2.5l6 4.5V5l-6 4.5v1z'; + break; + case 'video-alt3': + path = 'M19 15V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h13c1.1 0 2-.9 2-2zM8 14V6l6 4z'; + break; + case 'visibility': + path = 'M19.7 9.4C17.7 6 14 3.9 10 3.9S2.3 6 .3 9.4L0 10l.3.6c2 3.4 5.7 5.5 9.7 5.5s7.7-2.1 9.7-5.5l.3-.6-.3-.6zM10 14.1c-3.1 0-6-1.6-7.7-4.1C3.6 8 5.7 6.6 8 6.1c-.9.6-1.5 1.7-1.5 2.9 0 1.9 1.6 3.5 3.5 3.5s3.5-1.6 3.5-3.5c0-1.2-.6-2.3-1.5-2.9 2.3.5 4.4 1.9 5.7 3.9-1.7 2.5-4.6 4.1-7.7 4.1z'; + break; + case 'warning': + path = 'M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm1.13 9.38l.35-6.46H8.52l.35 6.46h2.26zm-.09 3.36c.24-.23.37-.55.37-.96 0-.42-.12-.74-.36-.97s-.59-.35-1.06-.35-.82.12-1.07.35-.37.55-.37.97c0 .41.13.73.38.96.26.23.61.34 1.06.34s.8-.11 1.05-.34z'; + break; + case 'welcome-add-page': + path = 'M17 7V4h-2V2h-3v1H3v15h11V9h1V7h2zm-1-2v1h-2v2h-1V6h-2V5h2V3h1v2h2z'; + break; + case 'welcome-comments': + path = 'M5 2h10c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-2l-5 5v-5H5c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2zm8.5 8.5L11 8l2.5-2.5-1-1L10 7 7.5 4.5l-1 1L9 8l-2.5 2.5 1 1L10 9l2.5 2.5z'; + break; + case 'welcome-learn-more': + path = 'M10 10L2.54 7.02 3 18H1l.48-11.41L0 6l10-4 10 4zm0-5c-.55 0-1 .22-1 .5s.45.5 1 .5 1-.22 1-.5-.45-.5-1-.5zm0 6l5.57-2.23c.71.94 1.2 2.07 1.36 3.3-.3-.04-.61-.07-.93-.07-2.55 0-4.78 1.37-6 3.41C8.78 13.37 6.55 12 4 12c-.32 0-.63.03-.93.07.16-1.23.65-2.36 1.36-3.3z'; + break; + case 'welcome-view-site': + path = 'M18 14V4c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1zm-8-8c2.3 0 4.4 1.14 6 3-1.6 1.86-3.7 3-6 3s-4.4-1.14-6-3c1.6-1.86 3.7-3 6-3zm2 3c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm2 8h3v1H3v-1h3v-1h8v1z'; + break; + case 'welcome-widgets-menus': + path = 'M19 16V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v13c0 .55.45 1 1 1h15c.55 0 1-.45 1-1zM4 4h13v4H4V4zm1 1v2h3V5H5zm4 0v2h3V5H9zm4 0v2h3V5h-3zm-8.5 5c.28 0 .5.22.5.5s-.22.5-.5.5-.5-.22-.5-.5.22-.5.5-.5zM6 10h4v1H6v-1zm6 0h5v5h-5v-5zm-7.5 2c.28 0 .5.22.5.5s-.22.5-.5.5-.5-.22-.5-.5.22-.5.5-.5zM6 12h4v1H6v-1zm7 0v2h3v-2h-3zm-8.5 2c.28 0 .5.22.5.5s-.22.5-.5.5-.5-.22-.5-.5.22-.5.5-.5zM6 14h4v1H6v-1z'; + break; + case 'welcome-write-blog': + path = 'M16.89 1.2l1.41 1.41c.39.39.39 1.02 0 1.41L14 8.33V18H3V3h10.67l1.8-1.8c.4-.39 1.03-.4 1.42 0zm-5.66 8.48l5.37-5.36-1.42-1.42-5.36 5.37-.71 2.12z'; + break; + case 'wordpress-alt': + path = 'M20 10c0-5.51-4.49-10-10-10C4.48 0 0 4.49 0 10c0 5.52 4.48 10 10 10 5.51 0 10-4.48 10-10zM7.78 15.37L4.37 6.22c.55-.02 1.17-.08 1.17-.08.5-.06.44-1.13-.06-1.11 0 0-1.45.11-2.37.11-.18 0-.37 0-.58-.01C4.12 2.69 6.87 1.11 10 1.11c2.33 0 4.45.87 6.05 2.34-.68-.11-1.65.39-1.65 1.58 0 .74.45 1.36.9 2.1.35.61.55 1.36.55 2.46 0 1.49-1.4 5-1.4 5l-3.03-8.37c.54-.02.82-.17.82-.17.5-.05.44-1.25-.06-1.22 0 0-1.44.12-2.38.12-.87 0-2.33-.12-2.33-.12-.5-.03-.56 1.2-.06 1.22l.92.08 1.26 3.41zM17.41 10c.24-.64.74-1.87.43-4.25.7 1.29 1.05 2.71 1.05 4.25 0 3.29-1.73 6.24-4.4 7.78.97-2.59 1.94-5.2 2.92-7.78zM6.1 18.09C3.12 16.65 1.11 13.53 1.11 10c0-1.3.23-2.48.72-3.59C3.25 10.3 4.67 14.2 6.1 18.09zm4.03-6.63l2.58 6.98c-.86.29-1.76.45-2.71.45-.79 0-1.57-.11-2.29-.33.81-2.38 1.62-4.74 2.42-7.1z'; + break; + case 'wordpress': + path = 'M20 10c0-5.52-4.48-10-10-10S0 4.48 0 10s4.48 10 10 10 10-4.48 10-10zM10 1.01c4.97 0 8.99 4.02 8.99 8.99s-4.02 8.99-8.99 8.99S1.01 14.97 1.01 10 5.03 1.01 10 1.01zM8.01 14.82L4.96 6.61c.49-.03 1.05-.08 1.05-.08.43-.05.38-1.01-.06-.99 0 0-1.29.1-2.13.1-.15 0-.33 0-.52-.01 1.44-2.17 3.9-3.6 6.7-3.6 2.09 0 3.99.79 5.41 2.09-.6-.08-1.45.35-1.45 1.42 0 .66.38 1.22.79 1.88.31.54.5 1.22.5 2.21 0 1.34-1.27 4.48-1.27 4.48l-2.71-7.5c.48-.03.75-.16.75-.16.43-.05.38-1.1-.05-1.08 0 0-1.3.11-2.14.11-.78 0-2.11-.11-2.11-.11-.43-.02-.48 1.06-.05 1.08l.84.08 1.12 3.04zm6.02 2.15L16.64 10s.67-1.69.39-3.81c.63 1.14.94 2.42.94 3.81 0 2.96-1.56 5.58-3.94 6.97zM2.68 6.77L6.5 17.25c-2.67-1.3-4.47-4.08-4.47-7.25 0-1.16.2-2.23.65-3.23zm7.45 4.53l2.29 6.25c-.75.27-1.57.42-2.42.42-.72 0-1.41-.11-2.06-.3z'; + break; + case 'yes-alt': + path = 'M10 2c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm-.615 12.66h-1.34l-3.24-4.54 1.34-1.25 2.57 2.4 5.14-5.93 1.34.94-5.81 8.38z'; + break; + case 'yes': + path = 'M14.83 4.89l1.34.94-5.81 8.38H9.02L5.78 9.67l1.34-1.25 2.57 2.4z'; + break; + } + + if ( ! path ) { + return null; + } + + const iconClass = getIconClassName( icon, className, ariaPressed ); + + return ( + <SVG + aria-hidden + role="img" + focusable="false" + className={ iconClass } + xmlns="http://www.w3.org/2000/svg" + width={ size } + height={ size } + viewBox="0 0 20 20" + { ...extraProps } + > + <Path d={ path } /> + </SVG> + ); + } +} diff --git a/gb-src/packages/components/src/dashicon/style.scss b/gb-src/packages/components/src/dashicon/style.scss new file mode 100644 index 0000000000000..086fe34394e16 --- /dev/null +++ b/gb-src/packages/components/src/dashicon/style.scss @@ -0,0 +1,4 @@ +svg.dashicon { + fill: currentColor; + outline: none; +} diff --git a/gb-src/packages/components/src/dashicon/test/index.js b/gb-src/packages/components/src/dashicon/test/index.js new file mode 100644 index 0000000000000..4d01950f3283e --- /dev/null +++ b/gb-src/packages/components/src/dashicon/test/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Dashicon from '../'; +import { SVG } from '../../primitives'; + +describe( 'Dashicon', () => { + describe( 'basic rendering', () => { + it( 'should render an empty null element when icon is not provided', () => { + const dashicon = shallow( <Dashicon /> ); + // Unrendered element. + expect( dashicon.type() ).toBeNull(); + } ); + + it( 'should render an empty null element when a non dashicon is provided', () => { + const dashicon = shallow( <Dashicon icon="zomg-never-gonna-be-an-icon-icon" /> ); + // Unrendered element. + expect( dashicon.type() ).toBeNull(); + } ); + + it( 'should render a SVG icon element when a matching icon is provided', () => { + const dashicon = shallow( <Dashicon icon="wordpress" /> ); + expect( dashicon.hasClass( 'dashicon' ) ).toBe( true ); + expect( dashicon.hasClass( 'dashicons-wordpress' ) ).toBe( true ); + expect( dashicon.type() ).toBe( SVG ); + expect( dashicon.prop( 'xmlns' ) ).toBe( 'http://www.w3.org/2000/svg' ); + expect( dashicon.prop( 'width' ) ).toBe( 20 ); + expect( dashicon.prop( 'height' ) ).toBe( 20 ); + expect( dashicon.prop( 'viewBox' ) ).toBe( '0 0 20 20' ); + } ); + + it( 'should render an additional class to the SVG element', () => { + const dashicon = shallow( <Dashicon icon="wordpress" className="capital-p-dangit" /> ); + expect( dashicon.hasClass( 'capital-p-dangit' ) ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/date-time/README.md b/gb-src/packages/components/src/date-time/README.md new file mode 100644 index 0000000000000..3cd2392b09e2f --- /dev/null +++ b/gb-src/packages/components/src/date-time/README.md @@ -0,0 +1,77 @@ +# DateTimePicker + +DateTimePicker is a React component that renders a calendar and clock for date and time selection. The calendar and clock components can be accessed individually using the `DatePicker` and `TimePicker` components respectively. + +![Date Time component](https://wordpress.org/gutenberg/files/2019/07/date-time-picker.png) + +## Best practices + +Date pickers should: + +- Use smart defaults and highlight the current date. + +## Usage + +Render a DateTimePicker. + +```jsx +import { DateTimePicker } from '@wordpress/components'; +import { __experimentalGetSettings } from '@wordpress/date'; +import { withState } from '@wordpress/compose'; + +const MyDateTimePicker = withState( { + date: new Date(), +} )( ( { date, setState } ) => { + const settings = __experimentalGetSettings(); + + // To know if the current timezone is a 12 hour time with look for an "a" in the time format. + // We also make sure this a is not escaped by a "/". + const is12HourTime = /a(?!\\)/i.test( + settings.formats.time + .toLowerCase() // Test only the lower case a + .replace( /\\\\/g, '' ) // Replace "//" with empty strings + .split( '' ).reverse().join( '' ) // Reverse the string and test for "a" not followed by a slash + ); + + return ( + <DateTimePicker + currentDate={ date } + onChange={ ( date ) => setState( { date } ) } + is12Hour={ is12HourTime } + /> + ); +} ); +``` + +## Props + +The component accepts the following props: + +### currentDate + +The current date and time at initialization. Optionally pass in a `null` value to specify no date is currently selected. + +- Type: `string` +- Required: No +- Default: today's date + +### onChange + +The function called when a new date or time has been selected. It is passed the `currentDate` as an argument. + +- Type: `Function` +- Required: Yes + +### is12Hour + +Whether we use a 12-hour clock. With a 12-hour clock, an AM/PM widget is displayed and the time format is assumed to be MM-DD-YYYY. + +- Type: `bool` +- Required: No + +### isInvalidDate + +A callback function which receives a Date object representing a day as an argument, and should return a Boolean to signify if the day is valid or not. + +- Type: `Function` +- Required: No diff --git a/gb-src/packages/components/src/date-time/date.js b/gb-src/packages/components/src/date-time/date.js new file mode 100644 index 0000000000000..7361494a61e68 --- /dev/null +++ b/gb-src/packages/components/src/date-time/date.js @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { DayPickerSingleDateController } from 'react-dates'; + +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; + +/** + * Module Constants + */ +const TIMEZONELESS_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; +const isRTL = () => document.documentElement.dir === 'rtl'; + +class DatePicker extends Component { + constructor() { + super( ...arguments ); + + this.onChangeMoment = this.onChangeMoment.bind( this ); + this.nodeRef = createRef(); + this.keepFocusInside = this.keepFocusInside.bind( this ); + } + + /* + * Todo: We should remove this function ASAP. + * It is kept because focus is lost when we click on the previous and next month buttons. + * This focus loss closes the date picker popover. + * Ideally we should add an upstream commit on react-dates to fix this issue. + */ + keepFocusInside() { + if ( ! this.nodeRef.current ) { + return; + } + // If focus was lost. + if ( ! document.activeElement || ! this.nodeRef.current.contains( document.activeElement ) ) { + // Retrieve the focus region div. + const focusRegion = this.nodeRef.current.querySelector( '.DayPicker_focusRegion' ); + if ( ! focusRegion ) { + return; + } + // Keep the focus on focus region. + focusRegion.focus(); + } + } + + onChangeMoment( newDate ) { + const { currentDate, onChange } = this.props; + + // If currentDate is null, use now as momentTime to designate hours, minutes, seconds. + const momentDate = currentDate ? moment( currentDate ) : moment(); + const momentTime = { + hours: momentDate.hours(), + minutes: momentDate.minutes(), + seconds: 0, + }; + + onChange( newDate.set( momentTime ).format( TIMEZONELESS_FORMAT ) ); + } + + /** + * Create a Moment object from a date string. With no currentDate supplied, default to a Moment + * object representing now. If a null value is passed, return a null value. + * + * @param {?string} currentDate Date representing the currently selected date or null to signify no selection. + * @return {?Moment} Moment object for selected date or null. + */ + getMomentDate( currentDate ) { + if ( null === currentDate ) { + return null; + } + return currentDate ? moment( currentDate ) : moment(); + } + + render() { + const { currentDate, isInvalidDate } = this.props; + + const momentDate = this.getMomentDate( currentDate ); + + return ( + <div + className="components-datetime__date" + ref={ this.nodeRef } + > + <DayPickerSingleDateController + date={ momentDate } + daySize={ 30 } + focused + hideKeyboardShortcutsPanel + // This is a hack to force the calendar to update on month or year change + // https://github.com/airbnb/react-dates/issues/240#issuecomment-361776665 + key={ `datepicker-controller-${ momentDate ? momentDate.format( 'MM-YYYY' ) : 'null' }` } + noBorder + numberOfMonths={ 1 } + onDateChange={ this.onChangeMoment } + transitionDuration={ 0 } + weekDayFormat="ddd" + isRTL={ isRTL() } + isOutsideRange={ ( date ) => { + return isInvalidDate && isInvalidDate( date.toDate() ); + } } + onPrevMonthClick={ this.keepFocusInside } + onNextMonthClick={ this.keepFocusInside } + /> + </div> + ); + } +} + +export default DatePicker; diff --git a/gb-src/packages/components/src/date-time/index.js b/gb-src/packages/components/src/date-time/index.js new file mode 100644 index 0000000000000..97bd891cac9a7 --- /dev/null +++ b/gb-src/packages/components/src/date-time/index.js @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +// Needed to initialise the default datepicker styles. +// See: https://github.com/airbnb/react-dates#initialize +import 'react-dates/initialize'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import { default as DatePicker } from './date'; +import { default as TimePicker } from './time'; + +export { DatePicker, TimePicker }; + +export class DateTimePicker extends Component { + constructor() { + super( ...arguments ); + + this.state = { calendarHelpIsVisible: false }; + + this.onClickDescriptionToggle = this.onClickDescriptionToggle.bind( this ); + } + + onClickDescriptionToggle() { + this.setState( { calendarHelpIsVisible: ! this.state.calendarHelpIsVisible } ); + } + + render() { + const { currentDate, is12Hour, onChange } = this.props; + + return ( + <div className="components-datetime"> + { ! this.state.calendarHelpIsVisible && ( + <> + <TimePicker + currentTime={ currentDate } + onChange={ onChange } + is12Hour={ is12Hour } + /> + <DatePicker + currentDate={ currentDate } + onChange={ onChange } + /> + </> + ) } + + { this.state.calendarHelpIsVisible && ( + <> + <div className="components-datetime__calendar-help"> + <h4>{ __( 'Click to Select' ) }</h4> + <ul> + <li>{ __( 'Click the right or left arrows to select other months in the past or the future.' ) }</li> + <li>{ __( 'Click the desired day to select it.' ) }</li> + </ul> + + <h4>{ __( 'Navigating with a keyboard' ) }</h4> + <ul> + <li> + <abbr aria-label={ _x( 'Enter', 'keyboard button' ) }>↵</abbr> + { ' ' /* JSX removes whitespace, but a space is required for screen readers. */ } + <span>{ __( 'Select the date in focus.' ) }</span> + </li> + <li> + <abbr aria-label={ __( 'Left and Right Arrows' ) }>←/→</abbr> + { ' ' /* JSX removes whitespace, but a space is required for screen readers. */ } + { __( 'Move backward (left) or forward (right) by one day.' ) } + </li> + <li> + <abbr aria-label={ __( 'Up and Down Arrows' ) }>↑/↓</abbr> + { ' ' /* JSX removes whitespace, but a space is required for screen readers. */ } + { __( 'Move backward (up) or forward (down) by one week.' ) } + </li> + <li> + <abbr aria-label={ __( 'Page Up and Page Down' ) }>{ __( 'PgUp/PgDn' ) }</abbr> + { ' ' /* JSX removes whitespace, but a space is required for screen readers. */ } + { __( 'Move backward (PgUp) or forward (PgDn) by one month.' ) } + </li> + <li> + <abbr aria-label={ __( 'Home and End' ) }>{ __( 'Home/End' ) }</abbr> + { ' ' /* JSX removes whitespace, but a space is required for screen readers. */ } + { __( 'Go to the first (home) or last (end) day of a week.' ) } + </li> + </ul> + + <Button isSmall onClick={ this.onClickDescriptionToggle }> + { __( 'Close' ) } + </Button> + </div> + </> + ) } + + { ! this.state.calendarHelpIsVisible && ( + <Button + className="components-datetime__date-help-button" + isLink + onClick={ this.onClickDescriptionToggle } + > + { __( 'Calendar Help' ) } + </Button> + ) } + </div> + ); + } +} diff --git a/gb-src/packages/components/src/date-time/style.scss b/gb-src/packages/components/src/date-time/style.scss new file mode 100644 index 0000000000000..2863fb908ff10 --- /dev/null +++ b/gb-src/packages/components/src/date-time/style.scss @@ -0,0 +1,224 @@ +// We can't reference this package with ~ because of how Lerna handles packages. +// Also, don't convert styles to RTL because react-dates uses an isRTL flag instead. +/*rtl:begin:ignore*/ +@import "node_modules/react-dates/lib/css/_datepicker"; +/*rtl:end:ignore*/ + +.components-datetime { + .components-datetime__calendar-help { + padding: $grid-size; + + h4 { + margin: 0; + } + } + + .components-datetime__date-help-button { + display: block; + margin-left: auto; + margin-right: $grid-size; + margin-top: 0.5em; + } + + fieldset { + border: 0; + padding: 0; + margin: 0; + } + + select, + input { + box-sizing: border-box; + height: 28px; + vertical-align: middle; + padding: 0; + @include input-style__neutral(); + } +} + +.components-datetime__date { + min-height: 236px; + border-top: 1px solid $light-gray-500; + margin-left: -$grid-size; + margin-right: -$grid-size; + + // Override external DatePicker styles. + .CalendarMonth_caption { + font-size: $default-font-size; + } + + .CalendarDay { + font-size: $default-font-size; + border: 1px solid transparent; + border-radius: $radius-round; + text-align: center; + } + + .CalendarDay__selected { + background: theme(primary); + + &:hover { + background: color(theme(primary) shade(15%)); + } + } + + .DayPickerNavigation_button__horizontalDefault { + padding: 2px 8px; + top: 20px; + + &:focus { + @include input-style__focus(); + } + } + + .DayPicker_weekHeader { + top: 50px; + } + + &.is-description-visible .DayPicker, + &.is-description-visible .components-datetime__date-help-button { + visibility: hidden; + } +} + +.components-datetime__time { + margin-bottom: 1em; + + fieldset { + margin-top: 0.5em; + position: relative; + } + + .components-datetime__time-field-am-pm fieldset { + margin-top: 0; + } + + .components-datetime__time-wrapper { + display: flex; + + .components-datetime__time-separator { + display: inline-block; + padding: 0 3px 0 0; + color: $dark-gray-500; + } + + .components-datetime__time-am-button { + margin-left: 8px; + margin-right: -1px; + border-radius: 3px 0 0 3px; + } + + .components-datetime__time-pm-button { + margin-left: -1px; + border-radius: 0 3px 3px 0; + } + + .components-datetime__time-am-button:focus, + .components-datetime__time-pm-button:focus { + position: relative; + z-index: 1; + } + + .components-datetime__time-am-button.is-toggled, + .components-datetime__time-pm-button.is-toggled { + background: $light-gray-300; + border-color: $dark-gray-100; + box-shadow: inset 0 2px 5px -3px $dark-gray-500; + &:focus { + box-shadow: + inset 0 2px 5px -3px $dark-gray-500, + 0 0 0 1px $white, + 0 0 0 3px $blue-medium-focus; + } + } + + .components-datetime__time-field { + + &-time { + /*rtl:ignore*/ + direction: ltr; + } + + + &.am-pm button { + font-size: 11px; + font-weight: 600; + } + + select { + margin-right: 4px; + + &:focus { + position: relative; + z-index: 1; + } + } + + input[type="number"] { + padding: 2px; + margin-right: 4px; + text-align: center; + -moz-appearance: textfield; + + &:focus { + position: relative; + z-index: 1; + } + + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + } + } + + // Makes the month appear before the day if time format uses AM/PM + // We are assuming MM-DD-YYY correlates with AM/PM + &.is-12-hour { + .components-datetime__time-field-day input { + margin: 0 -4px 0 0 !important; + border-radius: $radius-round-rectangle 0 0 $radius-round-rectangle !important; + } + .components-datetime__time-field-year input { + border-radius: 0 $radius-round-rectangle $radius-round-rectangle 0 !important; + } + } +} + +.components-datetime__time-legend { + font-weight: 600; + margin-top: 0.5em; + + &.invisible { + position: absolute; + top: -999em; + left: -999em; + } +} + +.components-datetime__time-field-hours-input, +.components-datetime__time-field-minutes-input, +.components-datetime__time-field-day-input { + width: 35px; +} + +.components-datetime__time-field-year-input { + width: 55px; +} + +.components-datetime__time-field-month-select { + max-width: 145px; +} + +// Hack to center the datepicker component within the popover. +// It sets its own styles so centering is tricky. +.components-popover .components-datetime__date { + padding-left: 4px; +} + +// Used to prevent z-index issues on mobile. +// See: https://github.com/WordPress/gutenberg/pull/7621#issuecomment-424322735 +.components-popover.edit-post-post-schedule__dialog.is-bottom.is-left { + z-index: 100000; +} diff --git a/gb-src/packages/components/src/date-time/test/__snapshots__/time.js.snap b/gb-src/packages/components/src/date-time/test/__snapshots__/time.js.snap new file mode 100644 index 0000000000000..d67e11da54846 --- /dev/null +++ b/gb-src/packages/components/src/date-time/test/__snapshots__/time.js.snap @@ -0,0 +1,689 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimePicker matches the snapshot when the is12hour prop is false 1`] = ` +<div + className="components-datetime__time" +> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Date + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-month" + key="render-month" + > + <select + aria-label="Month" + className="components-datetime__time-field-month-select" + onBlur={[Function]} + onChange={[Function]} + value="10" + > + <option + value="01" + > + January + </option> + <option + value="02" + > + February + </option> + <option + value="03" + > + March + </option> + <option + value="04" + > + April + </option> + <option + value="05" + > + May + </option> + <option + value="06" + > + June + </option> + <option + value="07" + > + July + </option> + <option + value="08" + > + August + </option> + <option + value="09" + > + September + </option> + <option + value="10" + > + October + </option> + <option + value="11" + > + November + </option> + <option + value="12" + > + December + </option> + </select> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-day" + key="render-day" + > + <input + aria-label="Day" + className="components-datetime__time-field-day-input" + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="18" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-year" + > + <input + aria-label="Year" + className="components-datetime__time-field-year-input" + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="1986" + /> + </div> + </div> + </fieldset> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Time + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-time" + > + <input + aria-label="Hours" + className="components-datetime__time-field-hours-input" + max={23} + min={0} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="23" + /> + <span + aria-hidden="true" + className="components-datetime__time-separator" + > + : + </span> + <input + aria-label="Minutes" + className="components-datetime__time-field-minutes-input" + max={59} + min={0} + onBlur={[Function]} + onChange={[Function]} + type="number" + value="00" + /> + </div> + </div> + </fieldset> +</div> +`; + +exports[`TimePicker matches the snapshot when the is12hour prop is specified 1`] = ` +<div + className="components-datetime__time" +> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Date + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-day" + key="render-day" + > + <input + aria-label="Day" + className="components-datetime__time-field-day-input" + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="18" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-month" + key="render-month" + > + <select + aria-label="Month" + className="components-datetime__time-field-month-select" + onBlur={[Function]} + onChange={[Function]} + value="10" + > + <option + value="01" + > + January + </option> + <option + value="02" + > + February + </option> + <option + value="03" + > + March + </option> + <option + value="04" + > + April + </option> + <option + value="05" + > + May + </option> + <option + value="06" + > + June + </option> + <option + value="07" + > + July + </option> + <option + value="08" + > + August + </option> + <option + value="09" + > + September + </option> + <option + value="10" + > + October + </option> + <option + value="11" + > + November + </option> + <option + value="12" + > + December + </option> + </select> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-year" + > + <input + aria-label="Year" + className="components-datetime__time-field-year-input" + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="1986" + /> + </div> + </div> + </fieldset> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Time + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-time" + > + <input + aria-label="Hours" + className="components-datetime__time-field-hours-input" + max={12} + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="11" + /> + <span + aria-hidden="true" + className="components-datetime__time-separator" + > + : + </span> + <input + aria-label="Minutes" + className="components-datetime__time-field-minutes-input" + max={59} + min={0} + onBlur={[Function]} + onChange={[Function]} + type="number" + value="00" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-am-pm" + > + <ForwardRef(Button) + aria-pressed={false} + className="components-datetime__time-am-button" + isDefault={true} + isToggled={false} + onClick={[Function]} + > + AM + </ForwardRef(Button)> + <ForwardRef(Button) + aria-pressed={true} + className="components-datetime__time-pm-button" + isDefault={true} + isToggled={true} + onClick={[Function]} + > + PM + </ForwardRef(Button)> + </div> + </div> + </fieldset> +</div> +`; + +exports[`TimePicker matches the snapshot when the is12hour prop is true 1`] = ` +<div + className="components-datetime__time" +> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Date + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-day" + key="render-day" + > + <input + aria-label="Day" + className="components-datetime__time-field-day-input" + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="18" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-month" + key="render-month" + > + <select + aria-label="Month" + className="components-datetime__time-field-month-select" + onBlur={[Function]} + onChange={[Function]} + value="10" + > + <option + value="01" + > + January + </option> + <option + value="02" + > + February + </option> + <option + value="03" + > + March + </option> + <option + value="04" + > + April + </option> + <option + value="05" + > + May + </option> + <option + value="06" + > + June + </option> + <option + value="07" + > + July + </option> + <option + value="08" + > + August + </option> + <option + value="09" + > + September + </option> + <option + value="10" + > + October + </option> + <option + value="11" + > + November + </option> + <option + value="12" + > + December + </option> + </select> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-year" + > + <input + aria-label="Year" + className="components-datetime__time-field-year-input" + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="1986" + /> + </div> + </div> + </fieldset> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Time + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-time" + > + <input + aria-label="Hours" + className="components-datetime__time-field-hours-input" + max={12} + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="11" + /> + <span + aria-hidden="true" + className="components-datetime__time-separator" + > + : + </span> + <input + aria-label="Minutes" + className="components-datetime__time-field-minutes-input" + max={59} + min={0} + onBlur={[Function]} + onChange={[Function]} + type="number" + value="00" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-am-pm" + > + <ForwardRef(Button) + aria-pressed={false} + className="components-datetime__time-am-button" + isDefault={true} + isToggled={false} + onClick={[Function]} + > + AM + </ForwardRef(Button)> + <ForwardRef(Button) + aria-pressed={true} + className="components-datetime__time-pm-button" + isDefault={true} + isToggled={true} + onClick={[Function]} + > + PM + </ForwardRef(Button)> + </div> + </div> + </fieldset> +</div> +`; + +exports[`TimePicker matches the snapshot when the is12hour prop is undefined 1`] = ` +<div + className="components-datetime__time" +> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Date + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-month" + key="render-month" + > + <select + aria-label="Month" + className="components-datetime__time-field-month-select" + onBlur={[Function]} + onChange={[Function]} + value="10" + > + <option + value="01" + > + January + </option> + <option + value="02" + > + February + </option> + <option + value="03" + > + March + </option> + <option + value="04" + > + April + </option> + <option + value="05" + > + May + </option> + <option + value="06" + > + June + </option> + <option + value="07" + > + July + </option> + <option + value="08" + > + August + </option> + <option + value="09" + > + September + </option> + <option + value="10" + > + October + </option> + <option + value="11" + > + November + </option> + <option + value="12" + > + December + </option> + </select> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-day" + key="render-day" + > + <input + aria-label="Day" + className="components-datetime__time-field-day-input" + min={1} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="18" + /> + </div> + <div + className="components-datetime__time-field components-datetime__time-field-year" + > + <input + aria-label="Year" + className="components-datetime__time-field-year-input" + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="1986" + /> + </div> + </div> + </fieldset> + <fieldset> + <legend + className="components-datetime__time-legend invisible" + > + Time + </legend> + <div + className="components-datetime__time-wrapper" + > + <div + className="components-datetime__time-field components-datetime__time-field-time" + > + <input + aria-label="Hours" + className="components-datetime__time-field-hours-input" + max={23} + min={0} + onBlur={[Function]} + onChange={[Function]} + step={1} + type="number" + value="23" + /> + <span + aria-hidden="true" + className="components-datetime__time-separator" + > + : + </span> + <input + aria-label="Minutes" + className="components-datetime__time-field-minutes-input" + max={59} + min={0} + onBlur={[Function]} + onChange={[Function]} + type="number" + value="00" + /> + </div> + </div> + </fieldset> +</div> +`; diff --git a/gb-src/packages/components/src/date-time/test/date.js b/gb-src/packages/components/src/date-time/test/date.js new file mode 100644 index 0000000000000..cd1b1e1e12e2b --- /dev/null +++ b/gb-src/packages/components/src/date-time/test/date.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import moment from 'moment'; + +/** + * Internal dependencies + */ +import DatePicker from '../date'; + +const TIMEZONELESS_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +describe( 'DatePicker', () => { + it( 'should pass down a moment object for currentDate', () => { + const currentDate = '1986-10-18T23:00:00'; + const wrapper = shallow( <DatePicker currentDate={ currentDate } /> ); + const date = wrapper.children().props().date; + expect( moment.isMoment( date ) ).toBe( true ); + expect( date.isSame( moment( currentDate ) ) ).toBe( true ); + } ); + + it( 'should pass down a null date when currentDate is set to null', () => { + const wrapper = shallow( <DatePicker currentDate={ null } /> ); + expect( wrapper.children().props().date ).toBeNull(); + } ); + + it( 'should pass down a moment object for now when currentDate is undefined', () => { + const wrapper = shallow( <DatePicker /> ); + const date = wrapper.children().props().date; + expect( moment.isMoment( date ) ).toBe( true ); + } ); + + describe( 'getMomentDate', () => { + it( 'should return a Moment object representing a given date string', () => { + const currentDate = '1986-10-18T23:00:00'; + const wrapper = shallow( <DatePicker /> ); + const momentDate = wrapper.instance().getMomentDate( currentDate ); + + expect( moment.isMoment( momentDate ) ).toBe( true ); + expect( momentDate.isSame( moment( currentDate ) ) ).toBe( true ); + } ); + + it( 'should return null when given a null agrument', () => { + const currentDate = null; + const wrapper = shallow( <DatePicker /> ); + const momentDate = wrapper.instance().getMomentDate( currentDate ); + + expect( momentDate ).toBeNull(); + } ); + + it( 'should return a Moment object representing now when given an undefined argument', () => { + const wrapper = shallow( <DatePicker /> ); + const momentDate = wrapper.instance().getMomentDate(); + + expect( moment.isMoment( momentDate ) ).toBe( true ); + } ); + } ); + + describe( 'onChangeMoment', () => { + it( 'should call onChange with a formated date of the input', () => { + const onChangeSpy = jest.fn(); + const currentDate = '1986-10-18T11:00:00'; + const wrapper = shallow( <DatePicker currentDate={ currentDate } onChange={ onChangeSpy } /> ); + const newDate = moment(); + + wrapper.instance().onChangeMoment( newDate ); + + expect( onChangeSpy ).toHaveBeenCalledWith( newDate.format( TIMEZONELESS_FORMAT ) ); + } ); + + it( 'should call onChange with hours, minutes, seconds of the current time when currentDate is undefined', () => { + let onChangeSpyArgument; + const onChangeSpy = ( arg ) => onChangeSpyArgument = arg; + const wrapper = shallow( <DatePicker onChange={ onChangeSpy } /> ); + const newDate = moment( '1986-10-18T11:00:00' ); + const current = moment(); + const newDateWithCurrentTime = newDate + .clone() + .set( { + hours: current.hours(), + minutes: current.minutes(), + seconds: current.seconds(), + } ); + wrapper.instance().onChangeMoment( newDate ); + + expect( moment( onChangeSpyArgument ).isSame( newDateWithCurrentTime, 'minute' ) ).toBe( true ); + } ); + + it( 'should call onChange with hours, minutes, seconds of the current time when currentDate is null', () => { + let onChangeSpyArgument; + const onChangeSpy = ( arg ) => onChangeSpyArgument = arg; + const wrapper = shallow( <DatePicker currentDate={ null } onChange={ onChangeSpy } /> ); + const newDate = moment( '1986-10-18T11:00:00' ); + const current = moment(); + const newDateWithCurrentTime = newDate + .clone() + .set( { + hours: current.hours(), + minutes: current.minutes(), + seconds: current.seconds(), + } ); + wrapper.instance().onChangeMoment( newDate ); + + expect( moment( onChangeSpyArgument ).isSame( newDateWithCurrentTime, 'minute' ) ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/date-time/test/time.js b/gb-src/packages/components/src/date-time/test/time.js new file mode 100644 index 0000000000000..3d61d058f330c --- /dev/null +++ b/gb-src/packages/components/src/date-time/test/time.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import TimePicker from '../time'; + +describe( 'TimePicker', () => { + it( 'matches the snapshot when the is12hour prop is true', () => { + const wrapper = shallow( <TimePicker currentTime="1986-10-18T23:00:00" is12Hour={ true } /> ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when the is12hour prop is false', () => { + const wrapper = shallow( <TimePicker currentTime="1986-10-18T23:00:00" is12Hour={ false } /> ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when the is12hour prop is specified', () => { + const wrapper = shallow( <TimePicker currentTime="1986-10-18T23:00:00" is12Hour /> ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when the is12hour prop is undefined', () => { + const wrapper = shallow( <TimePicker currentTime="1986-10-18T23:00:00" /> ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should call onChange with an updated day', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-day-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '10' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-10T11:00:00' ); + } ); + + it( 'should call onChange with an updated month', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-month-select' ).at( 0 ); + input.simulate( 'change', { target: { value: '12' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-12-18T11:00:00' ); + } ); + + it( 'should call onChange with an updated year', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-year-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '2018' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '2018-10-18T11:00:00' ); + } ); + + it( 'should call onChange with an updated hour (12-hour clock)', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-hours-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '10' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T10:00:00' ); + } ); + + it( 'should not call onChange with an updated hour (12-hour clock) if the hour is out of bounds', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-hours-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '22' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).not.toHaveBeenCalled(); + } ); + + it( 'should call onChange with an updated hour (24-hour clock)', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour={ false } /> ); + const input = picker.find( '.components-datetime__time-field-hours-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '22' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T22:00:00' ); + } ); + + it( 'should call onChange with an updated minute', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-minutes-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '10' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T11:10:00' ); + } ); + + it( 'should not call onChange with an updated minute if out of bounds', () => { + const onChangeSpy = jest.fn(); + const picker = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const input = picker.find( '.components-datetime__time-field-minutes-input' ).at( 0 ); + input.simulate( 'change', { target: { value: '99' } } ); + input.simulate( 'blur' ); + expect( onChangeSpy ).not.toHaveBeenCalled(); + } ); + + it( 'should switch to PM correctly', () => { + const onChangeSpy = jest.fn(); + const button = shallow( <TimePicker currentTime="1986-10-18T11:00:00" onChange={ onChangeSpy } is12Hour /> ); + const pmButton = button.find( '.components-datetime__time-pm-button' ); + pmButton.simulate( 'click' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T23:00:00' ); + } ); + + it( 'should switch to AM correctly', () => { + const onChangeSpy = jest.fn(); + const button = shallow( <TimePicker currentTime="1986-10-18T23:00:00" onChange={ onChangeSpy } is12Hour /> ); + const pmButton = button.find( '.components-datetime__time-am-button' ); + pmButton.simulate( 'click' ); + expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T11:00:00' ); + } ); +} ); diff --git a/gb-src/packages/components/src/date-time/time.js b/gb-src/packages/components/src/date-time/time.js new file mode 100644 index 0000000000000..0cfc8c7dc8ea6 --- /dev/null +++ b/gb-src/packages/components/src/date-time/time.js @@ -0,0 +1,330 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isInteger } from 'lodash'; +import moment from 'moment'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Button from '../button'; + +/** + * Module Constants + */ +const TIMEZONELESS_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +class TimePicker extends Component { + constructor() { + super( ...arguments ); + this.state = { + day: '', + month: '', + year: '', + hours: '', + minutes: '', + am: true, + date: null, + }; + this.changeDate = this.changeDate.bind( this ); + this.updateMonth = this.updateMonth.bind( this ); + this.onChangeMonth = this.onChangeMonth.bind( this ); + this.updateDay = this.updateDay.bind( this ); + this.onChangeDay = this.onChangeDay.bind( this ); + this.updateYear = this.updateYear.bind( this ); + this.onChangeYear = this.onChangeYear.bind( this ); + this.updateHours = this.updateHours.bind( this ); + this.updateMinutes = this.updateMinutes.bind( this ); + this.onChangeHours = this.onChangeHours.bind( this ); + this.onChangeMinutes = this.onChangeMinutes.bind( this ); + this.renderMonth = this.renderMonth.bind( this ); + this.renderDay = this.renderDay.bind( this ); + this.renderDayMonthFormat = this.renderDayMonthFormat.bind( this ); + } + + componentDidMount() { + this.syncState( this.props ); + } + + componentDidUpdate( prevProps ) { + const { currentTime, is12Hour } = this.props; + if ( + currentTime !== prevProps.currentTime || + is12Hour !== prevProps.is12Hour + ) { + this.syncState( this.props ); + } + } + /** + * Function that sets the date state and calls the onChange with a new date. + * The date is truncated at the minutes. + * + * @param {Object} newDate The date object. + */ + changeDate( newDate ) { + const dateWithStartOfMinutes = newDate.clone().startOf( 'minute' ); + this.setState( { date: dateWithStartOfMinutes } ); + this.props.onChange( newDate.format( TIMEZONELESS_FORMAT ) ); + } + + getMaxHours() { + return this.props.is12Hour ? 12 : 23; + } + + getMinHours() { + return this.props.is12Hour ? 1 : 0; + } + + syncState( { currentTime, is12Hour } ) { + const selected = currentTime ? moment( currentTime ) : moment(); + const day = selected.format( 'DD' ); + const month = selected.format( 'MM' ); + const year = selected.format( 'YYYY' ); + const minutes = selected.format( 'mm' ); + const am = selected.format( 'A' ); + const hours = selected.format( is12Hour ? 'hh' : 'HH' ); + const date = currentTime ? moment( currentTime ) : moment(); + this.setState( { day, month, year, minutes, hours, am, date } ); + } + + updateHours() { + const { is12Hour } = this.props; + const { am, hours, date } = this.state; + const value = parseInt( hours, 10 ); + if ( + ! isInteger( value ) || + ( is12Hour && ( value < 1 || value > 12 ) ) || + ( ! is12Hour && ( value < 0 || value > 23 ) ) + ) { + this.syncState( this.props ); + return; + } + + const newDate = is12Hour ? + date.clone().hours( am === 'AM' ? value % 12 : ( ( ( value % 12 ) + 12 ) % 24 ) ) : + date.clone().hours( value ); + this.changeDate( newDate ); + } + + updateMinutes() { + const { minutes, date } = this.state; + const value = parseInt( minutes, 10 ); + if ( ! isInteger( value ) || value < 0 || value > 59 ) { + this.syncState( this.props ); + return; + } + const newDate = date.clone().minutes( value ); + this.changeDate( newDate ); + } + + updateDay() { + const { day, date } = this.state; + const value = parseInt( day, 10 ); + if ( ! isInteger( value ) || value < 1 || value > 31 ) { + this.syncState( this.props ); + return; + } + const newDate = date.clone().date( value ); + this.changeDate( newDate ); + } + + updateMonth() { + const { month, date } = this.state; + const value = parseInt( month, 10 ); + if ( ! isInteger( value ) || value < 1 || value > 12 ) { + this.syncState( this.props ); + return; + } + const newDate = date.clone().month( value - 1 ); + this.changeDate( newDate ); + } + + updateYear() { + const { year, date } = this.state; + const value = parseInt( year, 10 ); + if ( ! isInteger( value ) || value < 0 || value > 9999 ) { + this.syncState( this.props ); + return; + } + const newDate = date.clone().year( value ); + this.changeDate( newDate ); + } + + updateAmPm( value ) { + return () => { + const { am, date, hours } = this.state; + if ( am === value ) { + return; + } + let newDate; + if ( value === 'PM' ) { + newDate = date.clone().hours( ( ( parseInt( hours, 10 ) % 12 ) + 12 ) % 24 ); + } else { + newDate = date.clone().hours( parseInt( hours, 10 ) % 12 ); + } + this.changeDate( newDate ); + }; + } + + onChangeDay( event ) { + this.setState( { day: event.target.value } ); + } + + onChangeMonth( event ) { + this.setState( { month: event.target.value } ); + } + + onChangeYear( event ) { + this.setState( { year: event.target.value } ); + } + + onChangeHours( event ) { + this.setState( { hours: event.target.value } ); + } + + onChangeMinutes( event ) { + const minutes = event.target.value; + this.setState( { + minutes: ( minutes === '' ) ? '' : ( '0' + minutes ).slice( -2 ), + } ); + } + + renderMonth( month ) { + return ( + <div key="render-month" className="components-datetime__time-field components-datetime__time-field-month"> + <select + aria-label={ __( 'Month' ) } + className="components-datetime__time-field-month-select" + value={ month } + onChange={ this.onChangeMonth } + onBlur={ this.updateMonth } + > + <option value="01">{ __( 'January' ) }</option> + <option value="02">{ __( 'February' ) }</option> + <option value="03">{ __( 'March' ) }</option> + <option value="04">{ __( 'April' ) }</option> + <option value="05">{ __( 'May' ) }</option> + <option value="06">{ __( 'June' ) }</option> + <option value="07">{ __( 'July' ) }</option> + <option value="08">{ __( 'August' ) }</option> + <option value="09">{ __( 'September' ) }</option> + <option value="10">{ __( 'October' ) }</option> + <option value="11">{ __( 'November' ) }</option> + <option value="12">{ __( 'December' ) }</option> + </select> + </div> + ); + } + + renderDay( day ) { + return ( + <div key="render-day" className="components-datetime__time-field components-datetime__time-field-day"> + <input + aria-label={ __( 'Day' ) } + className="components-datetime__time-field-day-input" + type="number" + value={ day } + step={ 1 } + min={ 1 } + onChange={ this.onChangeDay } + onBlur={ this.updateDay } + /> + </div> + ); + } + + renderDayMonthFormat( is12Hour ) { + const { day, month } = this.state; + const layout = [ this.renderDay( day ), this.renderMonth( month ) ]; + return is12Hour ? layout : layout.reverse(); + } + + render() { + const { is12Hour } = this.props; + const { year, minutes, hours, am } = this.state; + return ( + <div className={ classnames( 'components-datetime__time' ) }> + <fieldset> + <legend className="components-datetime__time-legend invisible">{ __( 'Date' ) }</legend> + <div className="components-datetime__time-wrapper"> + { this.renderDayMonthFormat( is12Hour ) } + <div className="components-datetime__time-field components-datetime__time-field-year"> + <input + aria-label={ __( 'Year' ) } + className="components-datetime__time-field-year-input" + type="number" + step={ 1 } + value={ year } + onChange={ this.onChangeYear } + onBlur={ this.updateYear } + /> + </div> + </div> + </fieldset> + + <fieldset> + <legend className="components-datetime__time-legend invisible">{ __( 'Time' ) }</legend> + <div className="components-datetime__time-wrapper"> + <div className="components-datetime__time-field components-datetime__time-field-time"> + <input + aria-label={ __( 'Hours' ) } + className="components-datetime__time-field-hours-input" + type="number" + step={ 1 } + min={ this.getMinHours() } + max={ this.getMaxHours() } + value={ hours } + onChange={ this.onChangeHours } + onBlur={ this.updateHours } + /> + <span + className="components-datetime__time-separator" + aria-hidden="true">:</span> + <input + aria-label={ __( 'Minutes' ) } + className="components-datetime__time-field-minutes-input" + type="number" + min={ 0 } + max={ 59 } + value={ minutes } + onChange={ this.onChangeMinutes } + onBlur={ this.updateMinutes } + /> + </div> + { is12Hour && ( + <div className="components-datetime__time-field components-datetime__time-field-am-pm"> + <Button + aria-pressed={ am === 'AM' } + isDefault + className="components-datetime__time-am-button" + isToggled={ am === 'AM' } + onClick={ this.updateAmPm( 'AM' ) } + > + { __( 'AM' ) } + </Button> + <Button + aria-pressed={ am === 'PM' } + isDefault + className="components-datetime__time-pm-button" + isToggled={ am === 'PM' } + onClick={ this.updateAmPm( 'PM' ) } + > + { __( 'PM' ) } + </Button> + </div> + ) } + </div> + </fieldset> + </div> + ); + } +} + +export default TimePicker; diff --git a/gb-src/packages/components/src/disabled/README.md b/gb-src/packages/components/src/disabled/README.md new file mode 100644 index 0000000000000..3b823b120be1a --- /dev/null +++ b/gb-src/packages/components/src/disabled/README.md @@ -0,0 +1,51 @@ +# Disabled + +Disabled is a component which disables descendant tabbable elements and prevents pointer interaction. + +## Usage + +Assuming you have a form component, you can disable all form inputs by wrapping the form with `<Disabled>`. + +```jsx +import { Button, Disabled, TextControl } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyDisabled = withState( { + isDisabled: true, +} )( ( { isDisabled, setState } ) => { + let input = <TextControl label="Input" onChange={ () => {} } />; + if ( isDisabled ) { + input = <Disabled>{ input }</Disabled>; + } + + const toggleDisabled = () => { + setState( ( state ) => ( { isDisabled: ! state.isDisabled } ) ); + }; + + return ( + <div> + { input } + <Button isPrimary onClick={ toggleDisabled }> + Toggle Disabled + </Button> + </div> + ); +} ); +``` + +A component can detect if it has been wrapped in a `<Disabled>` by accessing its [context](https://reactjs.org/docs/context.html) using `Disabled.Consumer`. + +```jsx +function CustomButton() { + return ( + <Disabled.Consumer> + { ( isDisabled ) => ( + <button + { ...this.props } + style={ { opacity: isDisabled ? 0.5 : 1 } } + /> + ) } + </Disabled.Consumer> + ); +} +``` diff --git a/gb-src/packages/components/src/disabled/index.js b/gb-src/packages/components/src/disabled/index.js new file mode 100644 index 0000000000000..73c9ecc9349d8 --- /dev/null +++ b/gb-src/packages/components/src/disabled/index.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { includes, debounce } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createContext, Component } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; + +const { Consumer, Provider } = createContext( false ); + +/** + * Names of control nodes which qualify for disabled behavior. + * + * See WHATWG HTML Standard: 4.10.18.5: "Enabling and disabling form controls: the disabled attribute". + * + * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute + * + * @type {string[]} + */ +const DISABLED_ELIGIBLE_NODE_NAMES = [ + 'BUTTON', + 'FIELDSET', + 'INPUT', + 'OPTGROUP', + 'OPTION', + 'SELECT', + 'TEXTAREA', +]; + +class Disabled extends Component { + constructor() { + super( ...arguments ); + + this.bindNode = this.bindNode.bind( this ); + this.disable = this.disable.bind( this ); + + // Debounce re-disable since disabling process itself will incur + // additional mutations which should be ignored. + this.debouncedDisable = debounce( this.disable, { leading: true } ); + } + + componentDidMount() { + this.disable(); + + this.observer = new window.MutationObserver( this.debouncedDisable ); + this.observer.observe( this.node, { + childList: true, + attributes: true, + subtree: true, + } ); + } + + componentWillUnmount() { + this.observer.disconnect(); + this.debouncedDisable.cancel(); + } + + bindNode( node ) { + this.node = node; + } + + disable() { + focus.focusable.find( this.node ).forEach( ( focusable ) => { + if ( includes( DISABLED_ELIGIBLE_NODE_NAMES, focusable.nodeName ) ) { + focusable.setAttribute( 'disabled', '' ); + } + + if ( focusable.hasAttribute( 'tabindex' ) ) { + focusable.removeAttribute( 'tabindex' ); + } + + if ( focusable.hasAttribute( 'contenteditable' ) ) { + focusable.setAttribute( 'contenteditable', 'false' ); + } + } ); + } + + render() { + const { className, ...props } = this.props; + return ( + <Provider value={ true }> + <div + ref={ this.bindNode } + className={ classnames( className, 'components-disabled' ) } + { ...props } + > + { this.props.children } + </div> + </Provider> + ); + } +} + +Disabled.Consumer = Consumer; + +export default Disabled; diff --git a/gb-src/packages/components/src/disabled/style.scss b/gb-src/packages/components/src/disabled/style.scss new file mode 100644 index 0000000000000..77e0dab63a0b8 --- /dev/null +++ b/gb-src/packages/components/src/disabled/style.scss @@ -0,0 +1,18 @@ +.components-disabled { + position: relative; + pointer-events: none; + + &::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + // Also make nested blocks unselectable. + * { + pointer-events: none; + } +} diff --git a/gb-src/packages/components/src/disabled/test/index.js b/gb-src/packages/components/src/disabled/test/index.js new file mode 100644 index 0000000000000..bd7c556163e4e --- /dev/null +++ b/gb-src/packages/components/src/disabled/test/index.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import TestUtils from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Disabled from '../'; + +jest.mock( '@wordpress/dom', () => { + const focus = require.requireActual( '../../../../dom/src' ).focus; + + return { + focus: { + ...focus, + focusable: { + ...focus.focusable, + find( context ) { + // In JSDOM, all elements have zero'd widths and height. + // This is a metric for focusable's `isVisible`, so find + // and apply an arbitrary non-zero width. + [ ...context.querySelectorAll( '*' ) ].forEach( ( element ) => { + Object.defineProperties( element, { + offsetWidth: { + get: () => 1, + }, + } ); + } ); + + return focus.focusable.find( ...arguments ); + }, + }, + }, + }; +} ); + +describe( 'Disabled', () => { + let MutationObserver; + + beforeAll( () => { + MutationObserver = window.MutationObserver; + window.MutationObserver = function() {}; + window.MutationObserver.prototype = { + observe() {}, + disconnect() {}, + }; + } ); + + afterAll( () => { + window.MutationObserver = MutationObserver; + } ); + + const Form = () => <form><input /><div contentEditable tabIndex="0" /></form>; + + it( 'will disable all fields', () => { + const wrapper = TestUtils.renderIntoDocument( <Disabled><Form /></Disabled> ); + + const input = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'input' ); + const div = TestUtils.scryRenderedDOMComponentsWithTag( wrapper, 'div' )[ 1 ]; + + expect( input.hasAttribute( 'disabled' ) ).toBe( true ); + expect( div.getAttribute( 'contenteditable' ) ).toBe( 'false' ); + expect( div.hasAttribute( 'tabindex' ) ).toBe( false ); + expect( div.hasAttribute( 'disabled' ) ).toBe( false ); + } ); + + it( 'should cleanly un-disable via reconciliation', () => { + // If this test suddenly starts failing, it means React has become + // smarter about reusing children into grandfather element when the + // parent is dropped, so we'd need to find another way to restore + // original form state. + // Using state for this test for easier manipulation of the child props. + class MaybeDisable extends Component { + constructor() { + super( ...arguments ); + this.state = { isDisabled: true }; + } + + render() { + return this.state.isDisabled ? + <Disabled><Form /></Disabled> : + <Form />; + } + } + + const wrapper = TestUtils.renderIntoDocument( <MaybeDisable /> ); + wrapper.setState( { isDisabled: false } ); + + const input = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'input' ); + const div = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'div' ); + + expect( input.hasAttribute( 'disabled' ) ).toBe( false ); + expect( div.getAttribute( 'contenteditable' ) ).toBe( 'true' ); + expect( div.hasAttribute( 'tabindex' ) ).toBe( true ); + } ); + + // Ideally, we'd have two more test cases here: + // + // - it( 'will disable all fields on component render change' ) + // - it( 'will disable all fields on sneaky DOM manipulation' ) + // + // Alas, JSDOM does not support MutationObserver: + // + // https://github.com/jsdom/jsdom/issues/639 + + describe( 'Consumer', () => { + class DisabledStatus extends Component { + render() { + return ( + <p> + <Disabled.Consumer> + { ( isDisabled ) => isDisabled ? 'Disabled' : 'Not disabled' } + </Disabled.Consumer> + </p> + ); + } + } + + test( "lets components know that they're disabled via context", () => { + const wrapper = TestUtils.renderIntoDocument( <Disabled><DisabledStatus /></Disabled> ); + const wrapperElement = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'p' ); + expect( wrapperElement.textContent ).toBe( 'Disabled' ); + } ); + + test( "lets components know that they're not disabled via context", () => { + const wrapper = TestUtils.renderIntoDocument( <DisabledStatus /> ); + const wrapperElement = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'p' ); + expect( wrapperElement.textContent ).toBe( 'Not disabled' ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/draggable/README.md b/gb-src/packages/components/src/draggable/README.md new file mode 100644 index 0000000000000..34d4fbecea28f --- /dev/null +++ b/gb-src/packages/components/src/draggable/README.md @@ -0,0 +1,101 @@ +# Draggable + +`Draggable` is a Component that provides a way to set up a a cross-browser (including IE) customisable drag image and the transfer data for the drag event. It decouples the drag handle and the element to drag: use it by wrapping the component that will become the drag handle and providing the DOM ID of the element to drag. + +Note that the drag handle needs to declare the `draggable="true"` property and bind the `Draggable`s `onDraggableStart` and `onDraggableEnd` event handlers to its own `onDragStart` and `onDragEnd` respectively. `Draggable` takes care of the logic to setup the drag image and the transfer data, but is not concerned with creating an actual DOM element that is draggable. + +## Props + +The component accepts the following props: + +### elementId + +The HTML id of the element to clone on drag + +- Type: `string` +- Required: Yes + +### transferData + +Arbitrary data object attached to the drag and drop event. + +- Type: `Object` +- Required: Yes + +### onDragStart + +A function to be called when dragging starts. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onDragEnd + +A function to be called when dragging ends. + +- Type: `Function` +- Required: No +- Default: `noop` + +## Usage + +```jsx +import { Dashicon, Draggable, Panel, PanelBody } from '@wordpress/components'; + +const MyDraggable = () => ( + <div id="draggable-panel"> + <Panel header="Draggable panel" > + <PanelBody> + <Draggable + elementId="draggable-panel" + transferData={ { } } + > + { + ( { onDraggableStart, onDraggableEnd } ) => ( + <Dashicon + icon="move" + onDragStart={ onDraggableStart } + onDragEnd={ onDraggableEnd } + draggable + /> + ) + } + </Draggable> + </PanelBody> + </Panel> + </div> +); +``` + +In case you want to call your own `dragstart` / `dragend` event handlers as well, you can pass them to `Draggable` and it'll take care of calling them after their own: + +```jsx +import { Dashicon, Draggable, Panel, PanelBody } from '@wordpress/components'; + +const MyDraggable = ( { onDragStart, onDragEnd } ) => ( + <div id="draggable-panel"> + <Panel header="Draggable panel" > + <PanelBody> + <Draggable + elementId="draggable-panel" + transferData={ { } } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + > + { + ( { onDraggableStart, onDraggableEnd } ) => ( + <Dashicon + icon="move" + onDragStart={ onDraggableStart } + onDragEnd={ onDraggableEnd } + draggable + /> + ) + } + </Draggable> + </PanelBody> + </Panel> + </div> +); +``` diff --git a/gb-src/packages/components/src/draggable/index.js b/gb-src/packages/components/src/draggable/index.js new file mode 100644 index 0000000000000..ac077468d8377 --- /dev/null +++ b/gb-src/packages/components/src/draggable/index.js @@ -0,0 +1,160 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSafeTimeout } from '@wordpress/compose'; + +const dragImageClass = 'components-draggable__invisible-drag-image'; +const cloneWrapperClass = 'components-draggable__clone'; +const cloneHeightTransformationBreakpoint = 700; +const clonePadding = 20; + +class Draggable extends Component { + constructor() { + super( ...arguments ); + + this.onDragStart = this.onDragStart.bind( this ); + this.onDragOver = this.onDragOver.bind( this ); + this.onDragEnd = this.onDragEnd.bind( this ); + this.resetDragState = this.resetDragState.bind( this ); + } + + componentWillUnmount() { + this.resetDragState(); + } + + /** + * Removes the element clone, resets cursor, and removes drag listener. + * + * @param {Object} event The non-custom DragEvent. + */ + onDragEnd( event ) { + const { onDragEnd = noop } = this.props; + event.preventDefault(); + + this.resetDragState(); + this.props.setTimeout( onDragEnd ); + } + + /** + * Updates positioning of element clone based on mouse movement during dragging. + * + * @param {Object} event The non-custom DragEvent. + */ + onDragOver( event ) { + this.cloneWrapper.style.top = + `${ parseInt( this.cloneWrapper.style.top, 10 ) + event.clientY - this.cursorTop }px`; + this.cloneWrapper.style.left = + `${ parseInt( this.cloneWrapper.style.left, 10 ) + event.clientX - this.cursorLeft }px`; + + // Update cursor coordinates. + this.cursorLeft = event.clientX; + this.cursorTop = event.clientY; + } + + /** + * This method does a couple of things: + * + * - Clones the current element and spawns clone over original element. + * - Adds a fake temporary drag image to avoid browser defaults. + * - Sets transfer data. + * - Adds dragover listener. + * + * @param {Object} event The non-custom DragEvent. + */ + onDragStart( event ) { + const { elementId, transferData, onDragStart = noop } = this.props; + const element = document.getElementById( elementId ); + if ( ! element ) { + event.preventDefault(); + return; + } + + // Set a fake drag image to avoid browser defaults. Remove from DOM + // right after. event.dataTransfer.setDragImage is not supported yet in + // IE, we need to check for its existence first. + if ( 'function' === typeof event.dataTransfer.setDragImage ) { + const dragImage = document.createElement( 'div' ); + dragImage.id = `drag-image-${ elementId }`; + dragImage.classList.add( dragImageClass ); + document.body.appendChild( dragImage ); + event.dataTransfer.setDragImage( dragImage, 0, 0 ); + this.props.setTimeout( () => { + document.body.removeChild( dragImage ); + } ); + } + + event.dataTransfer.setData( 'text', JSON.stringify( transferData ) ); + + // Prepare element clone and append to element wrapper. + const elementRect = element.getBoundingClientRect(); + const elementWrapper = element.parentNode; + const elementTopOffset = parseInt( elementRect.top, 10 ); + const elementLeftOffset = parseInt( elementRect.left, 10 ); + const clone = element.cloneNode( true ); + clone.id = `clone-${ elementId }`; + this.cloneWrapper = document.createElement( 'div' ); + this.cloneWrapper.classList.add( cloneWrapperClass ); + this.cloneWrapper.style.width = `${ elementRect.width + ( clonePadding * 2 ) }px`; + + if ( elementRect.height > cloneHeightTransformationBreakpoint ) { + // Scale down clone if original element is larger than 700px. + this.cloneWrapper.style.transform = 'scale(0.5)'; + this.cloneWrapper.style.transformOrigin = 'top left'; + // Position clone near the cursor. + this.cloneWrapper.style.top = `${ event.clientY - 100 }px`; + this.cloneWrapper.style.left = `${ event.clientX }px`; + } else { + // Position clone right over the original element (20px padding). + this.cloneWrapper.style.top = `${ elementTopOffset - clonePadding }px`; + this.cloneWrapper.style.left = `${ elementLeftOffset - clonePadding }px`; + } + + // Hack: Remove iFrames as it's causing the embeds drag clone to freeze + [ ...clone.querySelectorAll( 'iframe' ) ].forEach( ( child ) => child.parentNode.removeChild( child ) ); + + this.cloneWrapper.appendChild( clone ); + elementWrapper.appendChild( this.cloneWrapper ); + + // Mark the current cursor coordinates. + this.cursorLeft = event.clientX; + this.cursorTop = event.clientY; + // Update cursor to 'grabbing', document wide. + document.body.classList.add( 'is-dragging-components-draggable' ); + document.addEventListener( 'dragover', this.onDragOver ); + + this.props.setTimeout( onDragStart ); + } + + /** + * Cleans up drag state when drag has completed, or component unmounts + * while dragging. + */ + resetDragState() { + // Remove drag clone + document.removeEventListener( 'dragover', this.onDragOver ); + if ( this.cloneWrapper && this.cloneWrapper.parentNode ) { + this.cloneWrapper.parentNode.removeChild( this.cloneWrapper ); + this.cloneWrapper = null; + } + + // Reset cursor. + document.body.classList.remove( 'is-dragging-components-draggable' ); + } + + render() { + const { children } = this.props; + + return children( { + onDraggableStart: this.onDragStart, + onDraggableEnd: this.onDragEnd, + } ); + } +} + +export default withSafeTimeout( Draggable ); diff --git a/gb-src/packages/components/src/draggable/style.scss b/gb-src/packages/components/src/draggable/style.scss new file mode 100644 index 0000000000000..ad046f4e092a3 --- /dev/null +++ b/gb-src/packages/components/src/draggable/style.scss @@ -0,0 +1,20 @@ +body.is-dragging-components-draggable { + cursor: move;/* Fallback for IE/Edge < 14 */ + cursor: grabbing !important; +} + +.components-draggable__invisible-drag-image { + position: fixed; + left: -1000px; + height: 50px; + width: 50px; +} + +.components-draggable__clone { + position: fixed; + padding: 20px; + background: transparent; + pointer-events: none; + z-index: z-index(".components-draggable__clone"); + opacity: 0.8; +} diff --git a/gb-src/packages/components/src/drop-zone/README.md b/gb-src/packages/components/src/drop-zone/README.md new file mode 100644 index 0000000000000..567862b4ed792 --- /dev/null +++ b/gb-src/packages/components/src/drop-zone/README.md @@ -0,0 +1,67 @@ +# DropZone + +`DropZone` is a Component creating a drop zone area taking the full size of its parent element. It supports dropping files, HTML content or any other HTML drop event. To work properly this components needs to be wrapped in a `DropZoneProvider`. + +## Usage + +```jsx +import { DropZoneProvider, DropZone } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyDropZone = withState( { + hasDropped: false, +} )( ( { hasDropped, setState } ) => ( + <DropZoneProvider> + <div> + { hasDropped ? 'Dropped!' : 'Drop something here' } + <DropZone + onFilesDrop={ () => setState( { hasDropped: true } ) } + onHTMLDrop={ () => setState( { hasDropped: true } ) } + onDrop={ () => setState( { hasDropped: true } ) } + /> + </div> + </DropZoneProvider> +) ); +``` + +## Props + +The component accepts the following props: + +### className + +A CSS `class` to be _appended_ after the default `components-drop-zone` class. + +- Type: `String` +- Default: `undefined` + +### label + +A string to be shown within the drop zone area. + +- Type: `String` +- Default: `Drop files to upload` + +### onFilesDrop + +The function is called when dropping a file into the `DropZone`. It receives two arguments: an array of dropped files and a position object which the following shape: `{ x: 'left|right', y: 'top|bottom' }`. The position object indicates whether the drop event happened closer to the top or bottom edges and left or right ones. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onHTMLDrop + +The function is called when dropping a file into the `DropZone`. It receives two arguments: the HTML being dropped and a position object. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onDrop + +The function is generic drop handler called if the `onFilesDrop` or `onHTMLDrop` are not called. It receives two arguments: The drop `event` object and the position object. + +- Type: `Function` +- Required: No +- Default: `noop` diff --git a/gb-src/packages/components/src/drop-zone/index.js b/gb-src/packages/components/src/drop-zone/index.js new file mode 100644 index 0000000000000..07e384e22f947 --- /dev/null +++ b/gb-src/packages/components/src/drop-zone/index.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Dashicon from '../dashicon'; +import { DropZoneConsumer } from './provider'; + +const DropZone = ( props ) => ( + <DropZoneConsumer> + { ( { addDropZone, removeDropZone } ) => ( + <DropZoneComponent + addDropZone={ addDropZone } + removeDropZone={ removeDropZone } + { ...props } + /> + ) } + </DropZoneConsumer> +); + +class DropZoneComponent extends Component { + constructor() { + super( ...arguments ); + + this.dropZoneElement = createRef(); + this.dropZone = { + element: null, + onDrop: this.props.onDrop, + onFilesDrop: this.props.onFilesDrop, + onHTMLDrop: this.props.onHTMLDrop, + setState: this.setState.bind( this ), + }; + this.state = { + isDraggingOverDocument: false, + isDraggingOverElement: false, + position: null, + type: null, + }; + } + + componentDidMount() { + // Set element after the component has a node assigned in the DOM + this.dropZone.element = this.dropZoneElement.current; + this.props.addDropZone( this.dropZone ); + } + + componentWillUnmount() { + this.props.removeDropZone( this.dropZone ); + } + + render() { + const { className, label } = this.props; + const { isDraggingOverDocument, isDraggingOverElement, position, type } = this.state; + const classes = classnames( 'components-drop-zone', className, { + 'is-active': isDraggingOverDocument || isDraggingOverElement, + 'is-dragging-over-document': isDraggingOverDocument, + 'is-dragging-over-element': isDraggingOverElement, + 'is-close-to-top': position && position.y === 'top', + 'is-close-to-bottom': position && position.y === 'bottom', + 'is-close-to-left': position && position.x === 'left', + 'is-close-to-right': position && position.x === 'right', + [ `is-dragging-${ type }` ]: !! type, + } ); + + let children; + if ( isDraggingOverElement ) { + children = ( + <div className="components-drop-zone__content"> + <Dashicon + icon="upload" + size="40" + className="components-drop-zone__content-icon" + /> + <span className="components-drop-zone__content-text"> + { label ? label : __( 'Drop files to upload' ) } + </span> + </div> + ); + } + + return ( + <div ref={ this.dropZoneElement } className={ classes }> + { children } + </div> + ); + } +} + +export default DropZone; diff --git a/gb-src/packages/components/src/drop-zone/provider.js b/gb-src/packages/components/src/drop-zone/provider.js new file mode 100644 index 0000000000000..a0df1364b8ac7 --- /dev/null +++ b/gb-src/packages/components/src/drop-zone/provider.js @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import { isEqual, find, some, filter, throttle, includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createContext } from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +const { Provider, Consumer } = createContext( { + addDropZone: () => {}, + removeDropZone: () => {}, +} ); + +const getDragEventType = ( { dataTransfer } ) => { + if ( dataTransfer ) { + // Use lodash `includes` here as in the Edge browser `types` is implemented + // as a DomStringList, whereas in other browsers it's an array. `includes` + // happily works with both types. + if ( includes( dataTransfer.types, 'Files' ) ) { + return 'file'; + } + + if ( includes( dataTransfer.types, 'text/html' ) ) { + return 'html'; + } + } + + return 'default'; +}; + +const isTypeSupportedByDropZone = ( type, dropZone ) => { + return ( type === 'file' && dropZone.onFilesDrop ) || + ( type === 'html' && dropZone.onHTMLDrop ) || + ( type === 'default' && dropZone.onDrop ); +}; + +const isWithinElementBounds = ( element, x, y ) => { + const rect = element.getBoundingClientRect(); + /// make sure the rect is a valid rect + if ( rect.bottom === rect.top || rect.left === rect.right ) { + return false; + } + + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +}; + +class DropZoneProvider extends Component { + constructor() { + super( ...arguments ); + + // Event listeners + this.onDragOver = this.onDragOver.bind( this ); + this.onDrop = this.onDrop.bind( this ); + // Context methods so this component can receive data from consumers + this.addDropZone = this.addDropZone.bind( this ); + this.removeDropZone = this.removeDropZone.bind( this ); + // Utility methods + this.resetDragState = this.resetDragState.bind( this ); + this.toggleDraggingOverDocument = throttle( this.toggleDraggingOverDocument.bind( this ), 200 ); + + this.dropZones = []; + this.dropZoneCallbacks = { + addDropZone: this.addDropZone, + removeDropZone: this.removeDropZone, + }; + this.state = { + hoveredDropZone: -1, + isDraggingOverDocument: false, + position: null, + }; + } + + componentDidMount() { + window.addEventListener( 'dragover', this.onDragOver ); + window.addEventListener( 'mouseup', this.resetDragState ); + } + + componentWillUnmount() { + window.removeEventListener( 'dragover', this.onDragOver ); + window.removeEventListener( 'mouseup', this.resetDragState ); + } + + addDropZone( dropZone ) { + this.dropZones.push( dropZone ); + } + + removeDropZone( dropZone ) { + this.dropZones = filter( this.dropZones, ( dz ) => dz !== dropZone ); + } + + resetDragState() { + // Avoid throttled drag over handler calls + this.toggleDraggingOverDocument.cancel(); + + const { isDraggingOverDocument, hoveredDropZone } = this.state; + if ( ! isDraggingOverDocument && hoveredDropZone === -1 ) { + return; + } + + this.setState( { + hoveredDropZone: -1, + isDraggingOverDocument: false, + position: null, + } ); + + this.dropZones.forEach( ( dropZone ) => dropZone.setState( { + isDraggingOverDocument: false, + isDraggingOverElement: false, + position: null, + type: null, + } ) ); + } + + toggleDraggingOverDocument( event, dragEventType ) { + // In some contexts, it may be necessary to capture and redirect the + // drag event (e.g. atop an `iframe`). To accommodate this, you can + // create an instance of CustomEvent with the original event specified + // as the `detail` property. + // + // See: https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + const detail = window.CustomEvent && event instanceof window.CustomEvent ? event.detail : event; + + // Index of hovered dropzone. + const hoveredDropZones = filter( this.dropZones, ( dropZone ) => + isTypeSupportedByDropZone( dragEventType, dropZone ) && + isWithinElementBounds( dropZone.element, detail.clientX, detail.clientY ) + ); + + // Find the leaf dropzone not containing another dropzone + const hoveredDropZone = find( hoveredDropZones, ( zone ) => ( + ! some( hoveredDropZones, ( subZone ) => subZone !== zone && zone.element.parentElement.contains( subZone.element ) ) + ) ); + + const hoveredDropZoneIndex = this.dropZones.indexOf( hoveredDropZone ); + + let position = null; + + if ( hoveredDropZone ) { + const rect = hoveredDropZone.element.getBoundingClientRect(); + + position = { + x: detail.clientX - rect.left < rect.right - detail.clientX ? 'left' : 'right', + y: detail.clientY - rect.top < rect.bottom - detail.clientY ? 'top' : 'bottom', + }; + } + + // Optimisation: Only update the changed dropzones + let toUpdate = []; + + if ( ! this.state.isDraggingOverDocument ) { + toUpdate = this.dropZones; + } else if ( hoveredDropZoneIndex !== this.state.hoveredDropZone ) { + if ( this.state.hoveredDropZone !== -1 ) { + toUpdate.push( this.dropZones[ this.state.hoveredDropZone ] ); + } + if ( hoveredDropZone ) { + toUpdate.push( hoveredDropZone ); + } + } else if ( + hoveredDropZone && + hoveredDropZoneIndex === this.state.hoveredDropZone && + ! isEqual( position, this.state.position ) + ) { + toUpdate.push( hoveredDropZone ); + } + + // Notifying the dropzones + toUpdate.forEach( ( dropZone ) => { + const index = this.dropZones.indexOf( dropZone ); + const isDraggingOverDropZone = index === hoveredDropZoneIndex; + dropZone.setState( { + isDraggingOverDocument: isTypeSupportedByDropZone( dragEventType, dropZone ), + isDraggingOverElement: isDraggingOverDropZone, + position: isDraggingOverDropZone ? position : null, + type: isDraggingOverDropZone ? dragEventType : null, + } ); + } ); + + const newState = { + isDraggingOverDocument: true, + hoveredDropZone: hoveredDropZoneIndex, + position, + }; + if ( ! isShallowEqual( newState, this.state ) ) { + this.setState( newState ); + } + } + + onDragOver( event ) { + this.toggleDraggingOverDocument( event, getDragEventType( event ) ); + event.preventDefault(); + } + + onDrop( event ) { + // This seemingly useless line has been shown to resolve a Safari issue + // where files dragged directly from the dock are not recognized + event.dataTransfer && event.dataTransfer.files.length; // eslint-disable-line no-unused-expressions + + const { position, hoveredDropZone } = this.state; + const dragEventType = getDragEventType( event ); + const dropZone = this.dropZones[ hoveredDropZone ]; + this.resetDragState(); + + if ( dropZone ) { + switch ( dragEventType ) { + case 'file': + dropZone.onFilesDrop( [ ...event.dataTransfer.files ], position ); + break; + case 'html': + dropZone.onHTMLDrop( event.dataTransfer.getData( 'text/html' ), position ); + break; + case 'default': + dropZone.onDrop( event, position ); + } + } + + event.stopPropagation(); + event.preventDefault(); + } + + render() { + return ( + <div onDrop={ this.onDrop } className="components-drop-zone__provider"> + <Provider value={ this.dropZoneCallbacks }> + { this.props.children } + </Provider> + </div> + ); + } +} + +export default DropZoneProvider; +export { Consumer as DropZoneConsumer }; diff --git a/gb-src/packages/components/src/drop-zone/style.scss b/gb-src/packages/components/src/drop-zone/style.scss new file mode 100644 index 0000000000000..414918b3a44a5 --- /dev/null +++ b/gb-src/packages/components/src/drop-zone/style.scss @@ -0,0 +1,62 @@ +.components-drop-zone { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: z-index(".components-drop-zone"); + visibility: hidden; + opacity: 0; + transition: 0.3s opacity, 0.3s background-color, 0s visibility 0.3s; + @include reduce-motion("transition"); + border: 2px solid $blue-dark-900; + border-radius: 2px; + + &.is-active { + opacity: 1; + visibility: visible; + transition: 0.3s opacity, 0.3s background-color; + @include reduce-motion("transition"); + } + + &.is-dragging-over-element { + background-color: rgba($blue-dark-900, 0.8); + } +} + +.components-drop-zone__content { + position: absolute; + top: 50%; + left: 0; + right: 0; + z-index: z-index(".components-drop-zone__content"); + transform: translateY(-50%); + width: 100%; + text-align: center; + color: $white; + transition: transform 0.2s ease-in-out; + @include reduce-motion("transition"); +} + +.components-drop-zone.is-dragging-over-element .components-drop-zone__content { + transform: translateY(-50%) scale(1.05); +} + +.components-drop-zone__content-icon, +.components-drop-zone__content-text { + display: block; +} + +.components-drop-zone__content-icon { + margin: 0 auto; + line-height: 0; +} + + +.components-drop-zone__content-text { + font-family: $default-font; +} + +.components-drop-zone__provider { + height: 100%; +} diff --git a/gb-src/packages/components/src/dropdown-menu/README.md b/gb-src/packages/components/src/dropdown-menu/README.md new file mode 100644 index 0000000000000..3cab663adeb14 --- /dev/null +++ b/gb-src/packages/components/src/dropdown-menu/README.md @@ -0,0 +1,217 @@ +# DropdownMenu + +The DropdownMenu displays a list of actions (each contained in a MenuItem, MenuItemsChoice, or MenuGroup) in a compact way. It appears in a Popover after the user has interacted with an element (a button or icon) or when they perform a specific action. + +![An expanded DropdownMenu, containing a list of MenuItems.](https://wordpress.org/gutenberg/files/2019/01/DropdownMenuExample.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) + +## Anatomy + +![Anatomy of a DropdownMenu.](https://wordpress.org/gutenberg/files/2019/01/DropdownMenuAnatomy.png) + +1. Popover: a container component in which the DropdownMenu is wrapped. +2. Parent button: the icon or button that is used to toggle the display of the Popover containing the DropdownMenu. +3. MenuItem: the list items within the DropdownMenu. + +## Design guidelines + +### Usage + +#### When to use a DropdownMenu + +Use a DropdownMenu when you want users to: + +- Choose an action or change a setting from a list, AND +- Only see the available choices contextually. + +If you need to display all the available options at all times, consider using a Toolbar instead. + +![Use a DropdownMenu to display a list of actions after the user interacts with an icon.](https://wordpress.org/gutenberg/files/2019/01/DropdownMenuDo.png) + +**Do** +Use a DropdownMenu to display a list of actions after the user interacts with an icon. + +![Don’t use a DropdownMenu for important actions that should always be visible. Use a Toolbar instead.](https://wordpress.org/gutenberg/files/2019/01/DropdownMenuDont.png) + +**Don’t** +Don’t use a DropdownMenu for frequently used actions. Use a Toolbar instead. + +#### Behavior + +Generally, the parent button should have a triangular icon to the right of the icon or text to indicate that interacting with it will show a DropdownMenu. In rare cases where the parent button directly indicates that there'll be more content (through the use of an ellipsis or "More" label), this can be omitted. + +The parent button should retain the same visual styling regardless of whether the DropdownMenu is displayed or not. + +#### Placement + +The DropdownMenu should typically appear directly below, or below and to the left of, the parent button. If there isn’t enough space below to display the full DropdownMenu, it can be displayed instead above the parent button. + +## Development guidelines + +DropdownMenu is a React component to render an expandable menu of buttons. It is similar in purpose to a `<select>` element, with the distinction that it does not maintain a value. Instead, each option behaves as an action button. + +### Usage + +Render a Dropdown Menu with a set of controls: + +```jsx +import { DropdownMenu } from '@wordpress/components'; + +const MyDropdownMenu = () => ( + <DropdownMenu + icon="move" + label="Select a direction" + controls={ [ + { + title: 'Up', + icon: 'arrow-up-alt', + onClick: () => console.log( 'up' ) + }, + { + title: 'Right', + icon: 'arrow-right-alt', + onClick: () => console.log( 'right' ) + }, + { + title: 'Down', + icon: 'arrow-down-alt', + onClick: () => console.log( 'down' ) + }, + { + title: 'Left', + icon: 'arrow-left-alt', + onClick: () => console.log( 'left' ) + }, + ] } + /> +); +``` + +Alternatively, specify a `children` function which returns elements valid for use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`. + +```jsx +import { Fragment } from '@wordpress/element'; +import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; + +const MyDropdownMenu = () => ( + <DropdownMenu + icon="move" + label="Select a direction" + > + { ( { onClose } ) => ( + <Fragment> + <MenuGroup> + <MenuItem + icon="arrow-up-alt" + onClick={ onClose } + > + Move Up + </MenuItem> + <MenuItem + icon="arrow-down-alt" + onClick={ onClose } + > + Move Down + </MenuItem> + </MenuGroup> + <MenuGroup> + <MenuItem + icon="trash" + onClick={ onClose } + > + Remove + </MenuItem> + </MenuGroup> + </Fragment> + ) } + </DropdownMenu> +); +``` + +### Props + +The component accepts the following props: + +#### icon + +The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug to be shown in the collapsed menu button. + +- Type: `String|null` +- Required: No +- Default: `"menu"` + +See also: [https://developer.wordpress.org/resource/dashicons/](https://developer.wordpress.org/resource/dashicons/) + +#### hasArrowIndicator + +Whether to display an arrow indicator next to the icon. + +- Type: `Boolean` +- Required: No +- Default: `false` + +For backward compatibility, when `icon` is explicitly set to `null` then the arrow indicator will be displayed even when this flag is set to `false`. + +#### label + +A human-readable label to present as accessibility text on the focused collapsed menu button. + +- Type: `String` +- Required: Yes + +#### controls + +An array of objects describing the options to be shown in the expanded menu. + +Each object should include an `icon` [Dashicon](https://developer.wordpress.org/resource/dashicons/) slug string, a human-readable `title` string, `isDisabled` boolean flag and an `onClick` function callback to invoke when the option is selected. + +A valid DropdownMenu must specify one or the other of a `controls` or `children` prop. + +- Type: `Array` +- Required: No + +#### children + +A [function render prop](https://reactjs.org/docs/render-props.html#using-props-other-than-render) which should return an element or elements valid for use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`. Its first argument is a props object including the same values as given to a [`Dropdown`'s `renderContent`](/packages/components/src/dropdown#rendercontent) (`isOpen`, `onToggle`, `onClose`). + +A valid DropdownMenu must specify one or the other of a `controls` or `children` prop. + +- Type: `Function` +- Required: No + +See also: [https://developer.wordpress.org/resource/dashicons/](https://developer.wordpress.org/resource/dashicons/) + +#### className + +A class name to apply to the dropdown menu's toggle element wrapper. + +- Type: `String` +- Required: No + +#### popoverProps + +Properties of `popoverProps` object will be passed as props to the nested `Popover` component. +Use this object to modify props available for the `Popover` component that are not already exposed in the `DropdownMenu` component, e.g.: the direction in which the popover should open relative to its parent node set with `position` prop. + + - Type: `Object` + - Required: No + +#### toggleProps + +Properties of `toggleProps` object will be passed as props to the nested `IconButton` component in the `renderToggle` implementation of the `Dropdown` component used internally. +Use this object to modify props available for the `IconButton` component that are not already exposed in the `DropdownMenu` component, e.g.: the tooltip text displayed on hover set with `tooltip` prop. + + - Type: `Object` + - Required: No + +#### menuProps + +Properties of `menuProps` object will be passed as props to the nested `NavigableMenu` component in the `renderContent` implementation of the `Dropdown` component used internally. +Use this object to modify props available for the `NavigableMenu` component that are not already exposed in the `DropdownMenu` component, e.g.: the orientation of the menu set with `orientation` prop. + + - Type: `Object` + - Required: No diff --git a/gb-src/packages/components/src/dropdown-menu/index.js b/gb-src/packages/components/src/dropdown-menu/index.js new file mode 100644 index 0000000000000..e94b246231ad1 --- /dev/null +++ b/gb-src/packages/components/src/dropdown-menu/index.js @@ -0,0 +1,160 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { flatMap, isEmpty, isFunction } from 'lodash'; + +/** + * WordPress dependencies + */ +import { DOWN } from '@wordpress/keycodes'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; +import Dropdown from '../dropdown'; +import { NavigableMenu } from '../navigable-container'; + +function mergeProps( defaultProps = {}, props = {} ) { + const mergedProps = { + ...defaultProps, + ...props, + }; + + if ( props.className && defaultProps.className ) { + mergedProps.className = classnames( props.className, defaultProps.className ); + } + + return mergedProps; +} + +function DropdownMenu( { + children, + className, + controls, + hasArrowIndicator = false, + icon = 'menu', + label, + popoverProps, + toggleProps, + menuProps, + // The following props exist for backward compatibility. + menuLabel, + position, +} ) { + if ( menuLabel ) { + deprecated( '`menuLabel` prop in `DropdownComponent`', { + alternative: '`menuProps` object and its `aria-label` property', + plugin: 'Gutenberg', + } ); + } + + if ( position ) { + deprecated( '`position` prop in `DropdownComponent`', { + alternative: '`popoverProps` object and its `position` property', + plugin: 'Gutenberg', + } ); + } + + if ( isEmpty( controls ) && ! isFunction( children ) ) { + return null; + } + + // Normalize controls to nested array of objects (sets of controls) + let controlSets; + if ( ! isEmpty( controls ) ) { + controlSets = controls; + if ( ! Array.isArray( controlSets[ 0 ] ) ) { + controlSets = [ controlSets ]; + } + } + const mergedPopoverProps = mergeProps( { + className: 'components-dropdown-menu__popover', + position, + }, popoverProps ); + + return ( + <Dropdown + className={ classnames( 'components-dropdown-menu', className ) } + popoverProps={ mergedPopoverProps } + renderToggle={ ( { isOpen, onToggle } ) => { + const openOnArrowDown = ( event ) => { + if ( ! isOpen && event.keyCode === DOWN ) { + event.preventDefault(); + event.stopPropagation(); + onToggle(); + } + }; + const mergedToggleProps = mergeProps( { + className: classnames( 'components-dropdown-menu__toggle', { + 'is-opened': isOpen, + } ), + tooltip: label, + }, toggleProps ); + + return ( + <IconButton + { ...mergedToggleProps } + icon={ icon } + onClick={ onToggle } + onKeyDown={ openOnArrowDown } + aria-haspopup="true" + aria-expanded={ isOpen } + label={ label } + > + { ( ! icon || hasArrowIndicator ) && <span className="components-dropdown-menu__indicator" /> } + </IconButton> + ); + } } + renderContent={ ( props ) => { + const mergedMenuProps = mergeProps( { + 'aria-label': menuLabel || label, + className: 'components-dropdown-menu__menu', + }, menuProps ); + + return ( + <NavigableMenu + { ...mergedMenuProps } + role="menu" + > + { + isFunction( children ) ? + children( props ) : + null + } + { flatMap( controlSets, ( controlSet, indexOfSet ) => ( + controlSet.map( ( control, indexOfControl ) => ( + <IconButton + key={ [ indexOfSet, indexOfControl ].join() } + onClick={ ( event ) => { + event.stopPropagation(); + props.onClose(); + if ( control.onClick ) { + control.onClick(); + } + } } + className={ classnames( + 'components-dropdown-menu__menu-item', + { + 'has-separator': indexOfSet > 0 && indexOfControl === 0, + 'is-active': control.isActive, + }, + ) } + icon={ control.icon } + role="menuitem" + disabled={ control.isDisabled } + > + { control.title } + </IconButton> + ) ) + ) ) } + </NavigableMenu> + ); + } } + /> + ); +} + +export default DropdownMenu; diff --git a/gb-src/packages/components/src/dropdown-menu/index.native.js b/gb-src/packages/components/src/dropdown-menu/index.native.js new file mode 100644 index 0000000000000..65306eee3081f --- /dev/null +++ b/gb-src/packages/components/src/dropdown-menu/index.native.js @@ -0,0 +1,6 @@ + +function DropdownMenu() { + return null; +} + +export default DropdownMenu; diff --git a/gb-src/packages/components/src/dropdown-menu/style.scss b/gb-src/packages/components/src/dropdown-menu/style.scss new file mode 100644 index 0000000000000..9efe547e19d80 --- /dev/null +++ b/gb-src/packages/components/src/dropdown-menu/style.scss @@ -0,0 +1,119 @@ +.components-dropdown-menu { + padding: 3px; + display: flex; + + .components-dropdown-menu__toggle { + width: auto; + margin: 0; + padding: 4px; + border: $border-width solid transparent; + display: flex; + flex-direction: row; + + &.is-active, + &.is-active:hover { + box-shadow: none; + background-color: $dark-gray-500; + color: $white; + } + + &:focus::before { + top: -3px; + right: -3px; + bottom: -3px; + left: -3px; + } + + &:hover, + &:focus, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + @include formatting-button-style__hover(); + } + + // Add a dropdown arrow indicator. + .components-dropdown-menu__indicator::after { + @include dropdown-arrow(); + } + } +} + +.components-dropdown-menu__popover .components-popover__content { + width: 200px; +} + +.components-dropdown-menu__menu { + width: 100%; + padding: ($grid-size - $border-width) 0; + font-family: $default-font; + font-size: $default-font-size; + line-height: $default-line-height; + + .components-dropdown-menu__menu-item, + .components-menu-item { + width: 100%; + padding: 6px; + outline: none; + cursor: pointer; + margin-bottom: $grid-size-small; + + &.has-separator { + margin-top: 6px; + position: relative; + overflow: visible; + } + + &.has-separator::before { + display: block; + content: ""; + box-sizing: content-box; + background-color: $light-gray-500; + position: absolute; + top: -3px; + left: 0; + right: 0; + height: 1px; + } + + // Plain menu styles. + &:focus:not(:disabled):not([aria-disabled="true"]):not(.is-default) { + @include menu-style__focus(); + } + + &:hover, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + @include menu-style__hover(); + } + + // Formatting buttons + > svg { + border-radius: $radius-round-rectangle; + + // This assumes 20x20px dashicons. + padding: 2px; + width: $icon-button-size-small; + height: $icon-button-size-small; + margin: -1px $grid-size -1px 0; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default).is-active > svg { + @include formatting-button-style__active(); + } + } + + .components-menu-group:not(:last-child) { + border-bottom: $border-width solid $light-gray-500; + } + + .components-menu-item__button, + .components-menu-item__button.components-icon-button { + padding-left: 2rem; + + &.has-icon { + padding-left: 0.5rem; + } + + .dashicon { + margin-right: 4px; + } + } +} diff --git a/gb-src/packages/components/src/dropdown-menu/test/index.js b/gb-src/packages/components/src/dropdown-menu/test/index.js new file mode 100644 index 0000000000000..74e0089eed68b --- /dev/null +++ b/gb-src/packages/components/src/dropdown-menu/test/index.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { shallow, mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { DOWN } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import DropdownMenu from '../'; +import { IconButton, MenuItem, NavigableMenu } from '../../'; + +describe( 'DropdownMenu', () => { + const children = ( { onClose } ) => <MenuItem onClick={ onClose } />; + + let controls; + beforeEach( () => { + controls = [ + { + title: 'Up', + icon: 'arrow-up-alt', + onClick: jest.fn(), + }, + { + title: 'Right', + icon: 'arrow-right-alt', + onClick: jest.fn(), + }, + { + title: 'Down', + icon: 'arrow-down-alt', + onClick: jest.fn(), + }, + { + title: 'Left', + icon: 'arrow-left-alt', + onClick: jest.fn(), + }, + ]; + } ); + + describe( 'basic rendering', () => { + it( 'should render a null element when neither controls nor children are assigned', () => { + const wrapper = shallow( <DropdownMenu /> ); + + expect( wrapper.type() ).toBeNull(); + } ); + + it( 'should render a null element when controls are empty and children is not specified', () => { + const wrapper = shallow( <DropdownMenu controls={ [] } /> ); + + expect( wrapper.type() ).toBeNull(); + } ); + + it( 'should open menu on arrow down (controls)', () => { + const wrapper = mount( <DropdownMenu controls={ controls } /> ); + const button = wrapper.find( IconButton ).filter( '.components-dropdown-menu__toggle' ); + + button.simulate( 'keydown', { + stopPropagation: () => {}, + preventDefault: () => {}, + keyCode: DOWN, + } ); + + expect( wrapper.find( NavigableMenu ) ).toHaveLength( 1 ); + expect( wrapper.find( IconButton ).filter( '.components-dropdown-menu__menu-item' ) ).toHaveLength( controls.length ); + } ); + + it( 'should open menu on arrow down (children)', () => { + const wrapper = mount( <DropdownMenu children={ children } /> ); + const button = wrapper.find( IconButton ).filter( '.components-dropdown-menu__toggle' ); + + button.simulate( 'keydown', { + stopPropagation: () => {}, + preventDefault: () => {}, + keyCode: DOWN, + } ); + + expect( wrapper.find( NavigableMenu ) ).toHaveLength( 1 ); + + wrapper.find( MenuItem ).props().onClick(); + wrapper.update(); + + expect( wrapper.find( NavigableMenu ) ).toHaveLength( 0 ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/dropdown/README.md b/gb-src/packages/components/src/dropdown/README.md new file mode 100644 index 0000000000000..31f5de665bea3 --- /dev/null +++ b/gb-src/packages/components/src/dropdown/README.md @@ -0,0 +1,110 @@ +# Dropdown + +Dropdown is a React component to render a button that opens a floating content modal when clicked. +This components takes care of updating the state of the dropdown menu (opened/closed), handles closing the menu when clicking outside +and uses render props to render the button and the content. + +## Usage + + +```jsx +import { Button, Dropdown } from '@wordpress/components'; + +const MyDropdown = () => ( + <Dropdown + className="my-container-class-name" + contentClassName="my-popover-content-classname" + position="bottom right" + renderToggle={ ( { isOpen, onToggle } ) => ( + <Button isPrimary onClick={ onToggle } aria-expanded={ isOpen }> + Toggle Popover! + </Button> + ) } + renderContent={ () => ( + <div> + This is the content of the popover. + </div> + ) } + /> +); +``` + +## Props + +The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content. + +### className + +className of the global container + +- Type: `String` +- Required: No + +### contentClassName + +If you want to target the dropdown menu for styling purposes, you need to provide a contentClassName because it's not being rendered as a children of the container node. + +- Type: `String` +- Required: No + +### position + +The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis. + +- Type: `String` +- Required: No +- Default: `"top center"` + +### renderToggle + +A callback invoked to render the Dropdown Toggle Button. + +- Type: `Function` +- Required: Yes + +The first argument of the callback is an object containing the following properties: + + - `isOpen`: whether the dropdown menu is opened or not + - `onToggle`: A function switching the dropdown menu's state from open to closed and vice versa + - `onClose`: A function that closes the menu if invoked + +### renderContent + +A callback invoked to render the content of the dropdown menu. Its first argument is the same as the `renderToggle` prop. + +- Type: `Function` +- Required: Yes + +### expandOnMobile + +Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to avoid this behavior. + + - Type: `Boolean` + - Required: No + - Default: `false` + +### headerTitle + + Set this to customize the text that is shown in the dropdown's header when + it is fullscreen on mobile. + + - Type: `String` + - Required: No + + ### focusOnMount + + By default, the *first tabblable element* in the popover will receive focus when it mounts. This is the same as setting `focusOnMount` to `"firstElement"`. If you want to focus the container instead, you can set `focusOnMount` to `"container"`. + + Set this prop to `false` to disable focus changing entirely. This should only be set when an appropriately accessible substitute behavior exists. + + - Type: `String` or `Boolean` + - Required: No + - Default: `"firstElement"` + + ### popoverProps + +Properties of popoverProps object will be passed as props to the `Popover` component. +Use this o object to access properties/feature if the `Popover` component that are not already exposed in the `Dropdown`component, e.g.: the hability to have the popover without an arrow. + + - Type: `Object` + - Required: No diff --git a/gb-src/packages/components/src/dropdown/index.js b/gb-src/packages/components/src/dropdown/index.js new file mode 100644 index 0000000000000..6c5cffa6e5e4b --- /dev/null +++ b/gb-src/packages/components/src/dropdown/index.js @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Popover from '../popover'; + +class Dropdown extends Component { + constructor() { + super( ...arguments ); + + this.toggle = this.toggle.bind( this ); + this.close = this.close.bind( this ); + this.closeIfFocusOutside = this.closeIfFocusOutside.bind( this ); + + this.containerRef = createRef(); + + this.state = { + isOpen: false, + }; + } + + componentWillUnmount() { + const { isOpen } = this.state; + const { onToggle } = this.props; + if ( isOpen && onToggle ) { + onToggle( false ); + } + } + + componentDidUpdate( prevProps, prevState ) { + const { isOpen } = this.state; + const { onToggle } = this.props; + if ( prevState.isOpen !== isOpen && onToggle ) { + onToggle( isOpen ); + } + } + + toggle() { + this.setState( ( state ) => ( { + isOpen: ! state.isOpen, + } ) ); + } + + /** + * Closes the dropdown if a focus leaves the dropdown wrapper. This is + * intentionally distinct from `onClose` since focus loss from the popover + * is expected to occur when using the Dropdown's toggle button, in which + * case the correct behavior is to keep the dropdown closed. The same applies + * in case when focus is moved to the modal dialog. + */ + closeIfFocusOutside() { + if ( + ! this.containerRef.current.contains( document.activeElement ) && + ! document.activeElement.closest( '[role="dialog"]' ) + ) { + this.close(); + } + } + + close() { + this.setState( { isOpen: false } ); + } + + render() { + const { isOpen } = this.state; + const { + renderContent, + renderToggle, + position = 'bottom', + className, + contentClassName, + expandOnMobile, + headerTitle, + focusOnMount, + popoverProps, + } = this.props; + + const args = { isOpen, onToggle: this.toggle, onClose: this.close }; + + return ( + <div className={ className } ref={ this.containerRef }> + { renderToggle( args ) } + { isOpen && ( + <Popover + className={ contentClassName } + position={ position } + onClose={ this.close } + onFocusOutside={ this.closeIfFocusOutside } + expandOnMobile={ expandOnMobile } + headerTitle={ headerTitle } + focusOnMount={ focusOnMount } + { ...popoverProps } + > + { renderContent( args ) } + </Popover> + ) } + </div> + ); + } +} + +export default Dropdown; diff --git a/gb-src/packages/components/src/dropdown/index.native.js b/gb-src/packages/components/src/dropdown/index.native.js new file mode 100644 index 0000000000000..de7c7b7839613 --- /dev/null +++ b/gb-src/packages/components/src/dropdown/index.native.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +class Dropdown extends Component { + constructor() { + super( ...arguments ); + + this.toggle = this.toggle.bind( this ); + this.close = this.close.bind( this ); + + this.state = { + isOpen: false, + }; + } + + componentWillUnmount() { + const { isOpen } = this.state; + const { onToggle } = this.props; + if ( isOpen && onToggle ) { + onToggle( false ); + } + } + + componentDidUpdate( prevProps, prevState ) { + const { isOpen } = this.state; + const { onToggle } = this.props; + if ( prevState.isOpen !== isOpen && onToggle ) { + onToggle( isOpen ); + } + } + + toggle() { + this.setState( ( state ) => ( { + isOpen: ! state.isOpen, + } ) ); + } + + close() { + this.setState( { isOpen: false } ); + } + + render() { + const { isOpen } = this.state; + const { + renderContent, + renderToggle, + } = this.props; + + const args = { isOpen, onToggle: this.toggle, onClose: this.close }; + + return ( + <> + { renderToggle( args ) } + { isOpen && renderContent( args ) } + </> + ); + } +} + +export default Dropdown; diff --git a/gb-src/packages/components/src/dropdown/test/index.js b/gb-src/packages/components/src/dropdown/test/index.js new file mode 100644 index 0000000000000..3de873a76787b --- /dev/null +++ b/gb-src/packages/components/src/dropdown/test/index.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import TestUtils from 'react-dom/test-utils'; + +/** + * Internal dependencies + */ +import Dropdown from '../'; + +describe( 'Dropdown', () => { + const expectPopoverVisible = ( wrapper, visible ) => { + expect( + TestUtils.scryRenderedDOMComponentsWithClass( wrapper, 'components-popover' ) ) + .toHaveLength( visible ? 1 : 0 ); + }; + const buttonElement = ( wrapper ) => TestUtils.findRenderedDOMComponentWithTag( + wrapper, + 'button' + ); + const openCloseElement = ( wrapper, className ) => TestUtils + .findRenderedDOMComponentWithClass( wrapper, className ); + + it( 'should toggle the dropdown properly', () => { + const expectButtonExpanded = ( wrapper, expanded ) => { + expect( + TestUtils.scryRenderedDOMComponentsWithTag( wrapper, 'button' ) ) + .toHaveLength( 1 ); + expect( + buttonElement( wrapper ).getAttribute( 'aria-expanded' ) + ).toBe( expanded.toString() ); + }; + + const wrapper = TestUtils.renderIntoDocument( <Dropdown + className="container" + contentClassName="content" + renderToggle={ ( { isOpen, onToggle } ) => ( + <button aria-expanded={ isOpen } onClick={ onToggle }>Toggleee</button> + ) } + renderContent={ () => <span>test</span> } + /> ); + + expectButtonExpanded( wrapper, false ); + expectPopoverVisible( wrapper, false ); + + TestUtils.Simulate.click( buttonElement( wrapper ) ); + + expectButtonExpanded( wrapper, true ); + expectPopoverVisible( wrapper, true ); + } ); + + it( 'should close the dropdown when calling onClose', () => { + const wrapper = TestUtils.renderIntoDocument( <Dropdown + className="container" + contentClassName="content" + renderToggle={ ( { isOpen, onToggle, onClose } ) => [ + <button key="open" className="open" aria-expanded={ isOpen } onClick={ onToggle }>Toggleee</button>, + <button key="close" className="close" onClick={ onClose } >closee</button>, + ] } + renderContent={ () => null } + /> ); + + expectPopoverVisible( wrapper, false ); + + TestUtils.Simulate.click( openCloseElement( wrapper, 'open' ) ); + + expectPopoverVisible( wrapper, true ); + + TestUtils.Simulate.click( openCloseElement( wrapper, 'close' ) ); + + expectPopoverVisible( wrapper, false ); + } ); +} ); diff --git a/gb-src/packages/components/src/external-link/README.md b/gb-src/packages/components/src/external-link/README.md new file mode 100644 index 0000000000000..73fe8b5c3fbdd --- /dev/null +++ b/gb-src/packages/components/src/external-link/README.md @@ -0,0 +1,11 @@ +# ExternalLink + +## Usage + +```jsx +import { ExternalLink } from '@wordpress/components'; + +const MyExternalLink = () => ( + <ExternalLink href="https://wordpress.org">WordPress.org</ExternalLink> +); +``` diff --git a/gb-src/packages/components/src/external-link/index.js b/gb-src/packages/components/src/external-link/index.js new file mode 100644 index 0000000000000..68bf56394b8f8 --- /dev/null +++ b/gb-src/packages/components/src/external-link/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { compact, uniq } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Dashicon from '../dashicon'; + +export function ExternalLink( { href, children, className, rel = '', ...additionalProps }, ref ) { + rel = uniq( compact( [ + ...rel.split( ' ' ), + 'external', + 'noreferrer', + 'noopener', + ] ) ).join( ' ' ); + const classes = classnames( 'components-external-link', className ); + return ( + // eslint-disable-next-line react/jsx-no-target-blank + <a { ...additionalProps } className={ classes } href={ href } target="_blank" rel={ rel } ref={ ref }> + { children } + <span className="screen-reader-text"> + { + /* translators: accessibility text */ + __( '(opens in a new tab)' ) + } + </span> + <Dashicon icon="external" className="components-external-link__icon" /> + </a> + ); +} + +export default forwardRef( ExternalLink ); diff --git a/gb-src/packages/components/src/external-link/style.scss b/gb-src/packages/components/src/external-link/style.scss new file mode 100644 index 0000000000000..8bb70186ce054 --- /dev/null +++ b/gb-src/packages/components/src/external-link/style.scss @@ -0,0 +1,6 @@ +.components-external-link__icon { + width: 1.4em; + height: 1.4em; + margin: -0.2em 0.1em 0; + vertical-align: middle; +} diff --git a/gb-src/packages/components/src/focal-point-picker/README.md b/gb-src/packages/components/src/focal-point-picker/README.md new file mode 100644 index 0000000000000..7afb26f278152 --- /dev/null +++ b/gb-src/packages/components/src/focal-point-picker/README.md @@ -0,0 +1,68 @@ +# Focal Point Picker + +Focal Point Picker is a component which creates a UI for identifying the most important visual point of an image. This component addresses a specific problem: with large background images it is common to see undesirable crops, especially when viewing on smaller viewports such as mobile phones. This component allows the selection of the point with the most important visual information and returns it as a pair of numbers between 0 and 1. This value can be easily converted into the CSS `background-position` attribute, and will ensure that the focal point is never cropped out, regardless of viewport. + +Example focal point picker value: `{ x: 0.5, y: 0.1 }` +Corresponding CSS: `background-position: 50% 10%;` + +## Usage + +```jsx +import { FocalPointPicker } from '@wordpress/components'; + +const MyFocalPointPicker = withState( { + focalPoint: { + x: 0.5, + y: 0.5 + }, +} )( ( { focalPoint, setState } ) => { + const url = '/path/to/image'; + const dimensions = { + width: 400, + height: 100 + }; + return ( + <FocalPointPicker + url={ url } + dimensions={ dimensions } + value={ focalPoint } + onChange={ ( focalPoint ) => setState( { focalPoint } ) } + /> + ) +} ); + +/* Example function to render the CSS styles based on Focal Point Picker value */ +const renderImageContainerWithFocalPoint = ( url, focalPoint ) => { + const style = { + backgroundImage: `url(${ url })` , + backgroundPosition: `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%` + } + return <div style={ style } />; +}; +``` + +## Props + +### `url` + +- Type: `Text` +- Required: Yes +- Description: URL of the image to be displayed + +### `dimensions` + +- Type: `Object` +- Required: Yes +- Description: An object describing the height and width of the image. Requires two paramaters: `height`, `width`. + +### `value` + +- Type: `Object` +- Required: Yes +- Description: The focal point. Should be an object containing `x` and `y` params. + +### `onChange` + +- Type: `Function` +- Required: Yes +- Description: Callback which is called when the focal point changes. diff --git a/gb-src/packages/components/src/focal-point-picker/index.js b/gb-src/packages/components/src/focal-point-picker/index.js new file mode 100644 index 0000000000000..d502ebbded0e0 --- /dev/null +++ b/gb-src/packages/components/src/focal-point-picker/index.js @@ -0,0 +1,246 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BaseControl from '../base-control'; +import withFocusOutside from '../higher-order/with-focus-outside'; +import { Path, SVG } from '../primitives'; + +const TEXTCONTROL_MIN = 0; +const TEXTCONTROL_MAX = 100; + +export class FocalPointPicker extends Component { + constructor( props ) { + super( props ); + this.onMouseMove = this.onMouseMove.bind( this ); + this.state = { + isDragging: false, + bounds: {}, + percentages: props.value, + }; + this.containerRef = createRef(); + this.imageRef = createRef(); + this.horizontalPositionChanged = this.horizontalPositionChanged.bind( this ); + this.verticalPositionChanged = this.verticalPositionChanged.bind( this ); + this.onLoad = this.onLoad.bind( this ); + } + componentDidUpdate( prevProps ) { + if ( prevProps.url !== this.props.url ) { + this.setState( { + isDragging: false, + } ); + } + } + calculateBounds() { + const bounds = { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }; + if ( ! this.imageRef.current ) { + return bounds; + } + const dimensions = { + width: this.imageRef.current.clientWidth, + height: this.imageRef.current.clientHeight, + }; + const pickerDimensions = this.pickerDimensions(); + const widthRatio = pickerDimensions.width / dimensions.width; + const heightRatio = pickerDimensions.height / dimensions.height; + if ( heightRatio >= widthRatio ) { + bounds.width = bounds.right = pickerDimensions.width; + bounds.height = dimensions.height * widthRatio; + bounds.top = ( pickerDimensions.height - bounds.height ) / 2; + bounds.bottom = bounds.top + bounds.height; + } else { + bounds.height = bounds.bottom = pickerDimensions.height; + bounds.width = dimensions.width * heightRatio; + bounds.left = ( pickerDimensions.width - bounds.width ) / 2; + bounds.right = bounds.left + bounds.width; + } + return bounds; + } + onLoad() { + this.setState( { + bounds: this.calculateBounds(), + } ); + } + onMouseMove( event ) { + const { isDragging, bounds } = this.state; + const { onChange } = this.props; + + if ( isDragging ) { + const pickerDimensions = this.pickerDimensions(); + const cursorPosition = { + left: event.pageX - pickerDimensions.left, + top: event.pageY - pickerDimensions.top, + }; + const left = Math.max( + bounds.left, + Math.min( + cursorPosition.left, bounds.right + ) + ); + const top = Math.max( + bounds.top, + Math.min( + cursorPosition.top, bounds.bottom + ) + ); + const percentages = { + x: ( left - bounds.left ) / ( pickerDimensions.width - ( bounds.left * 2 ) ), + y: ( top - bounds.top ) / ( pickerDimensions.height - ( bounds.top * 2 ) ), + }; + this.setState( { percentages }, function() { + onChange( { + x: this.state.percentages.x, + y: this.state.percentages.y, + } ); + } ); + } + } + fractionToPercentage( fraction ) { + return Math.round( fraction * 100 ); + } + horizontalPositionChanged( event ) { + this.positionChangeFromTextControl( 'x', event.target.value ); + } + verticalPositionChanged( event ) { + this.positionChangeFromTextControl( 'y', event.target.value ); + } + positionChangeFromTextControl( axis, value ) { + const { onChange } = this.props; + const { percentages } = this.state; + const cleanValue = Math.max( Math.min( parseInt( value ), 100 ), 0 ); + percentages[ axis ] = cleanValue ? cleanValue / 100 : 0; + this.setState( { percentages }, function() { + onChange( { + x: this.state.percentages.x, + y: this.state.percentages.y, + } ); + } ); + } + pickerDimensions() { + if ( this.containerRef.current ) { + return { + width: this.containerRef.current.clientWidth, + height: this.containerRef.current.clientHeight, + top: this.containerRef.current.getBoundingClientRect().top + document.body.scrollTop, + left: this.containerRef.current.getBoundingClientRect().left, + }; + } + return { + width: 0, + height: 0, + left: 0, + top: 0, + }; + } + handleFocusOutside() { + this.setState( { + isDragging: false, + } ); + } + render() { + const { instanceId, url, value, label, help, className } = this.props; + const { bounds, isDragging, percentages } = this.state; + const pickerDimensions = this.pickerDimensions(); + const iconCoordinates = { + left: ( value.x * ( pickerDimensions.width - ( bounds.left * 2 ) ) ) + bounds.left, + top: ( value.y * ( pickerDimensions.height - ( bounds.top * 2 ) ) ) + bounds.top, + }; + const iconContainerStyle = { + left: `${ iconCoordinates.left }px`, + top: `${ iconCoordinates.top }px`, + }; + const iconContainerClasses = classnames( + 'components-focal-point-picker__icon_container', + isDragging ? 'is-dragging' : null + ); + const id = `inspector-focal-point-picker-control-${ instanceId }`; + const horizontalPositionId = `inspector-focal-point-picker-control-horizontal-position-${ instanceId }`; + const verticalPositionId = `inspector-focal-point-picker-control-vertical-position-${ instanceId }`; + return ( + <BaseControl label={ label } id={ id } help={ help } className={ className }> + <div className="components-focal-point-picker-wrapper"> + <div + className="components-focal-point-picker" + onMouseDown={ () => this.setState( { isDragging: true } ) } + onDragStart={ () => this.setState( { isDragging: true } ) } + onMouseUp={ () => this.setState( { isDragging: false } ) } + onDrop={ () => this.setState( { isDragging: false } ) } + onMouseMove={ this.onMouseMove } + ref={ this.containerRef } + role="button" + tabIndex="-1" + > + <img + alt="Dimensions helper" + onLoad={ this.onLoad } + ref={ this.imageRef } + src={ url } + draggable="false" + /> + <div className={ iconContainerClasses } style={ iconContainerStyle }> + <SVG className="components-focal-point-picker__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"> + <Path className="components-focal-point-picker__icon-outline" d="M15 1C7.3 1 1 7.3 1 15s6.3 14 14 14 14-6.3 14-14S22.7 1 15 1zm0 22c-4.4 0-8-3.6-8-8s3.6-8 8-8 8 3.6 8 8-3.6 8-8 8z" /> + <Path className="components-focal-point-picker__icon-fill" d="M15 3C8.4 3 3 8.4 3 15s5.4 12 12 12 12-5.4 12-12S21.6 3 15 3zm0 22C9.5 25 5 20.5 5 15S9.5 5 15 5s10 4.5 10 10-4.5 10-10 10z" /> + </SVG> + </div> + </div> + </div> + <div className="components-focal-point-picker_position-display-container"> + <BaseControl label={ __( 'Horizontal Pos.' ) } id={ horizontalPositionId }> + <input + className="components-text-control__input" + id={ horizontalPositionId } + max={ TEXTCONTROL_MAX } + min={ TEXTCONTROL_MIN } + onChange={ this.horizontalPositionChanged } + type="number" + value={ this.fractionToPercentage( percentages.x ) } + /> + <span>%</span> + </BaseControl> + <BaseControl label={ __( 'Vertical Pos.' ) } id={ verticalPositionId }> + <input + className="components-text-control__input" + id={ verticalPositionId } + max={ TEXTCONTROL_MAX } + min={ TEXTCONTROL_MIN } + onChange={ this.verticalPositionChanged } + type="number" + value={ this.fractionToPercentage( percentages.y ) } + /> + <span>%</span> + </BaseControl> + </div> + </BaseControl> + ); + } +} + +FocalPointPicker.defaultProps = { + url: null, + value: { + x: 0.5, + y: 0.5, + }, + onChange: () => {}, +}; + +export default compose( [ withInstanceId, withFocusOutside ] )( FocalPointPicker ); diff --git a/gb-src/packages/components/src/focal-point-picker/style.scss b/gb-src/packages/components/src/focal-point-picker/style.scss new file mode 100644 index 0000000000000..f93f2e57ad688 --- /dev/null +++ b/gb-src/packages/components/src/focal-point-picker/style.scss @@ -0,0 +1,75 @@ +.components-focal-point-picker-wrapper { + background-color: transparent; + border: 1px solid $light-gray-500; + height: 200px; + width: 100%; + padding: 14px; +} + +.components-focal-point-picker { + align-items: center; + cursor: pointer; + display: flex; + height: 100%; + justify-content: center; + position: relative; + width: 100%; + + img { + height: auto; + max-height: 100%; + max-width: 100%; + width: auto; + user-select: none; + } +} + +.components-focal-point-picker__icon_container { + background-color: transparent; + cursor: grab; + height: 30px; + opacity: 0.8; + position: absolute; + will-change: transform; + width: 30px; + z-index: 10000; + + &.is-dragging { + cursor: grabbing; + } +} + +.components-focal-point-picker__icon { + display: block; + height: 100%; + left: -15px; + position: absolute; + top: -15px; + width: 100%; + + .components-focal-point-picker__icon-outline { + fill: $white; + } + + .components-focal-point-picker__icon-fill { + fill: theme(primary); + } +} + +.components-focal-point-picker_position-display-container { + margin: 1em 0; + display: flex; + + .components-base-control__field { + margin: 0 1em 0 0; + } + + input[type="number"].components-text-control__input { // Needs specificity to override padding. + max-width: 4em; + padding: 6px 4px; + } + + span { + margin: 0 0 0 0.2em; + } +} diff --git a/gb-src/packages/components/src/focusable-iframe/README.md b/gb-src/packages/components/src/focusable-iframe/README.md new file mode 100644 index 0000000000000..323298d3f6a63 --- /dev/null +++ b/gb-src/packages/components/src/focusable-iframe/README.md @@ -0,0 +1,36 @@ +# Focusable Iframe + +`<FocusableIframe />` is a component rendering an `iframe` element enhanced to support focus events. By default, it is not possible to detect when an iframe is focused or clicked within. This enhanced component uses a technique which checks whether the target of a window `blur` event is the iframe, inferring that this has resulted in the focus of the iframe. It dispatches an emulated [`FocusEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent) on the iframe element with event bubbling, so a parent component binding its own `onFocus` event will account for focus transitioning within the iframe. + +## Usage + +Use as you would a standard `iframe`. You may pass `onFocus` directly as the callback to be invoked when the iframe receives focus, or on an ancestor component since the event will bubble. + +```jsx +import { FocusableIframe } from '@wordpress/components'; + +const MyFocusableIframe = () => ( + <FocusableIframe + src="/my-iframe-url" + onFocus={ () => console.log( 'iframe is focused' ) } + /> +); +``` + +## Props + +Any props aside from those listed below will be passed to the `FocusableIframe` will be passed through to the underlying `iframe` element. + +### `onFocus` + +- Type: `Function` +- Required: No + +Callback to invoke when iframe receives focus. Passes an emulated `FocusEvent` object as the first argument. + +### `iframeRef` + +- Type: `wp.element.Ref` +- Required: No + +If a reference to the underlying DOM element is needed, pass `iframeRef` as the result of a `wp.element.createRef` called from your component. diff --git a/gb-src/packages/components/src/focusable-iframe/index.js b/gb-src/packages/components/src/focusable-iframe/index.js new file mode 100644 index 0000000000000..24581406d9d65 --- /dev/null +++ b/gb-src/packages/components/src/focusable-iframe/index.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { withGlobalEvents } from '@wordpress/compose'; + +/** + * Browser dependencies + */ + +const { FocusEvent } = window; + +class FocusableIframe extends Component { + constructor( props ) { + super( ...arguments ); + + this.checkFocus = this.checkFocus.bind( this ); + + this.node = props.iframeRef || createRef(); + } + + /** + * Checks whether the iframe is the activeElement, inferring that it has + * then received focus, and calls the `onFocus` prop callback. + */ + checkFocus() { + const iframe = this.node.current; + + if ( document.activeElement !== iframe ) { + return; + } + + const focusEvent = new FocusEvent( 'focus', { bubbles: true } ); + iframe.dispatchEvent( focusEvent ); + + const { onFocus } = this.props; + if ( onFocus ) { + onFocus( focusEvent ); + } + } + + render() { + // Disable reason: The rendered iframe is a pass-through component, + // assigning props inherited from the rendering parent. It's the + // responsibility of the parent to assign a title. + + /* eslint-disable jsx-a11y/iframe-has-title */ + return ( + <iframe + ref={ this.node } + { ...omit( this.props, [ 'iframeRef', 'onFocus' ] ) } + /> + ); + /* eslint-enable jsx-a11y/iframe-has-title */ + } +} + +export default withGlobalEvents( { + blur: 'checkFocus', +} )( FocusableIframe ); diff --git a/gb-src/packages/components/src/font-size-picker/README.md b/gb-src/packages/components/src/font-size-picker/README.md new file mode 100644 index 0000000000000..6024e1403b09b --- /dev/null +++ b/gb-src/packages/components/src/font-size-picker/README.md @@ -0,0 +1,99 @@ +# FontSizePicker + +FontSizePicker is a React component that renders a UI that allows users to select a font size. +The component renders a series of buttons that allow the user to select predefined (common) font sizes and contains a range slider that enables the user to select custom font sizes (by choosing the value. + +## Usage + + +```jsx +import { FontSizePicker } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; + +... +const MyFontSizePicker = withState( { + fontSize: 16, +} )( ( { fontSize, setState } ) => { + const fontSizes = [ + { + name: __( 'Small' ), + slug: 'small', + size: 12, + }, + { + name: __( 'Big' ), + slug: 'big', + size: 26, + }, + ]; + const fallbackFontSize = 16; + + return ( + <FontSizePicker + fontSizes={ fontSizes } + value={ fontSize } + fallbackFontSize={ fallbackFontSize } + onChange={ ( newFontSize ) => { + setState( { fontSize: newFontSize } ); + } } + /> + ); +} ); + +... + +<MyFontSizePicker /> +``` + +## Props + +The component accepts the following props: + +### disableCustomFontSizes + +If `true`, it will not be possible to choose a custom fontSize. The user will be forced to pick one of the pre-defined sizes passed in fontSizes. + +- Type: `Boolean` +- Required: no +- Default: `false` + +### fallbackFontSize + +If no value exists, this prop defines the starting position for the font size picker slider. Only relevant if `withSlider` is `true`. + +- Type: `Number` +- Required: No + +### fontSizes + +An array of font size objects. The object should contain properties size, name, and slug. +The property `size` contains a number with the font size value, in `px`. +The `name` property includes a label for that font size e.g.: `Small`. +The `slug` property is a string with a unique identifier for the font size. Used for the class generation process. + +- Type: `Array` +- Required: No + +### onChange + +A function that receives the new font size value. +If onChange is called without any parameter, it should reset the value, attending to what reset means in that context, e.g., set the font size to undefined or set the font size a starting value. + +- Type: `function` +- Required: Yes + +### value + +The current font size value. + +- Type: `Number` +- Required: No + +### withSlider + +If `true`, the UI will contain a slider, instead of a numeric text input field. If `false`, no slider will be present. + +- Type: `Boolean` +- Required: no +- Default: `false` diff --git a/gb-src/packages/components/src/font-size-picker/index.js b/gb-src/packages/components/src/font-size-picker/index.js new file mode 100644 index 0000000000000..a52f6ccd841d2 --- /dev/null +++ b/gb-src/packages/components/src/font-size-picker/index.js @@ -0,0 +1,118 @@ + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import RangeControl from '../range-control'; +import SelectControl from '../select-control'; + +function getSelectValueFromFontSize( fontSizes, value ) { + if ( value ) { + const fontSizeValue = fontSizes.find( ( font ) => font.size === value ); + return fontSizeValue ? fontSizeValue.slug : 'custom'; + } + return 'normal'; +} + +function getSelectOptions( optionsArray ) { + return [ + ...optionsArray.map( ( option ) => ( { value: option.slug, label: option.name } ) ), + { value: 'custom', label: __( 'Custom' ) }, + ]; +} + +function FontSizePicker( { + fallbackFontSize, + fontSizes = [], + disableCustomFontSizes = false, + onChange, + value, + withSlider = false, +} ) { + const [ currentSelectValue, setCurrentSelectValue ] = useState( getSelectValueFromFontSize( fontSizes, value ) ); + + if ( disableCustomFontSizes && ! fontSizes.length ) { + return null; + } + + const onChangeValue = ( event ) => { + const newValue = event.target.value; + setCurrentSelectValue( getSelectValueFromFontSize( fontSizes, Number( newValue ) ) ); + if ( newValue === '' ) { + onChange( undefined ); + return; + } + onChange( Number( newValue ) ); + }; + + const onSelectChangeValue = ( eventValue ) => { + setCurrentSelectValue( eventValue ); + const selectedFont = fontSizes.find( ( font ) => font.slug === eventValue ); + if ( selectedFont ) { + onChange( selectedFont.size ); + } + }; + + return ( + <fieldset> + <legend> + { __( 'Font Size' ) } + </legend> + <div className="components-font-size-picker__controls"> + { ( fontSizes.length > 0 ) && + <SelectControl + className={ 'components-font-size-picker__select' } + label={ 'Choose preset' } + hideLabelFromVision={ true } + value={ currentSelectValue } + onChange={ onSelectChangeValue } + options={ getSelectOptions( fontSizes ) } + /> + } + { ( ! withSlider && ! disableCustomFontSizes ) && + <input + className="components-range-control__number" + type="number" + onChange={ onChangeValue } + aria-label={ __( 'Custom' ) } + value={ value || '' } + /> + } + <Button + className="components-color-palette__clear" + type="button" + disabled={ value === undefined } + onClick={ () => { + onChange( undefined ); + setCurrentSelectValue( getSelectValueFromFontSize( fontSizes, undefined ) ); + } } + isSmall + isDefault + > + { __( 'Reset' ) } + </Button> + </div> + { withSlider && + <RangeControl + className="components-font-size-picker__custom-input" + label={ __( 'Custom Size' ) } + value={ value || '' } + initialPosition={ fallbackFontSize } + onChange={ onChange } + min={ 12 } + max={ 100 } + beforeIcon="editor-textcolor" + afterIcon="editor-textcolor" + /> + } + </fieldset> + ); +} + +export default FontSizePicker; diff --git a/gb-src/packages/components/src/font-size-picker/style.scss b/gb-src/packages/components/src/font-size-picker/style.scss new file mode 100644 index 0000000000000..00c140099f7f9 --- /dev/null +++ b/gb-src/packages/components/src/font-size-picker/style.scss @@ -0,0 +1,34 @@ +.components-font-size-picker__controls { + max-width: $sidebar-width - ( 2 * $panel-padding ); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $grid-size * 3; + + // Apply the same height as the isSmall Reset button. + .components-range-control__number { + height: 30px; + margin-left: 0; + + // Show the reset button as disabled until a value is entered. + &[value=""] + .components-button { + cursor: default; + opacity: 0.3; + pointer-events: none; + } + } +} + +// needed to override CSS set in https://github.com/WordPress/gutenberg/blob/9c438f93a7215d50d1efc0492c308e4cbaa59c52/packages/edit-post/src/components/sidebar/settings-sidebar/style.scss#L6 +.components-font-size-picker__select.components-font-size-picker__select.components-font-size-picker__select.components-font-size-picker__select, +.components-font-size-picker__select .components-base-control__field { + margin-bottom: 0; +} + +.components-font-size-picker__custom-input { + .components-range-control__slider + .dashicon { + width: 30px; + height: 30px; + } +} + diff --git a/gb-src/packages/components/src/form-file-upload/README.md b/gb-src/packages/components/src/form-file-upload/README.md new file mode 100644 index 0000000000000..969119eaa7552 --- /dev/null +++ b/gb-src/packages/components/src/form-file-upload/README.md @@ -0,0 +1,68 @@ +# FormFileUpload + +## Usage + +```jsx +import { FormFileUpload } from '@wordpress/components'; + +const MyFormFileUpload = () => ( + <FormFileUpload + accept="image/*" + onChange={ () => console.log('new image') } + > + Upload + </FormFileUpload> +); +``` + +## Props + +The component accepts the following props. Props not included in this set will be passed to the `IconButton` component. + +### accept + +A string passed to `input` element that tells the browser which file types can be upload to the upload by the user use. e.g: `image/*,video/*`. +More information about this string is available in https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers. + +- Type: `String` +- Required: No + + +### children + +Children are passed as children of `IconButton`. + +- Type: `Boolean` +- Required: No + +### icon + +The icon to render. Supported values are: Dashicons (specified as strings), functions, WPComponent instances and `null`. + +- Type: `String|Function|WPComponent|null` +- Required: No +- Default: `null` + + +### multiple + +Whether to allow multiple selection of files or not. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### onChange + +Callback function passed directly to the `input` file element. + +- Type: `Function` +- Required: Yes + +### render + +Optional callback function used to render the UI. If passed the component does not render any UI and calls this function to render it. +This function receives an object with the property `openFileDialog`. The property is a function that when called opens the browser window to upload files. + +- Type: `Function` +- Required: No diff --git a/gb-src/packages/components/src/form-file-upload/index.js b/gb-src/packages/components/src/form-file-upload/index.js new file mode 100644 index 0000000000000..1314e4d5b85ae --- /dev/null +++ b/gb-src/packages/components/src/form-file-upload/index.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; + +class FormFileUpload extends Component { + constructor() { + super( ...arguments ); + this.openFileDialog = this.openFileDialog.bind( this ); + this.bindInput = this.bindInput.bind( this ); + } + + openFileDialog() { + this.input.click(); + } + + bindInput( ref ) { + this.input = ref; + } + + render() { + const { + accept, + children, + icon = 'upload', + multiple = false, + onChange, + render, + ...props + } = this.props; + + const ui = render ? + render( { openFileDialog: this.openFileDialog } ) : ( + <IconButton + icon={ icon } + onClick={ this.openFileDialog } + { ...props } + > + { children } + </IconButton> + ); + return ( + <div className="components-form-file-upload"> + { ui } + <input + type="file" + ref={ this.bindInput } + multiple={ multiple } + style={ { display: 'none' } } + accept={ accept } + onChange={ onChange } + /> + </div> + ); + } +} + +export default FormFileUpload; diff --git a/gb-src/packages/components/src/form-file-upload/style.scss b/gb-src/packages/components/src/form-file-upload/style.scss new file mode 100644 index 0000000000000..3acfdffecf312 --- /dev/null +++ b/gb-src/packages/components/src/form-file-upload/style.scss @@ -0,0 +1,5 @@ +.components-form-file-upload { + .components-button.is-large { + padding-left: 6px; + } +} diff --git a/gb-src/packages/components/src/form-file-upload/test/index.js b/gb-src/packages/components/src/form-file-upload/test/index.js new file mode 100644 index 0000000000000..5a81c898d81bb --- /dev/null +++ b/gb-src/packages/components/src/form-file-upload/test/index.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import FormFileUpload from '../'; + +describe( 'InserterMenu', () => { + it( 'should show an Icon Button and a hidden input', () => { + const wrapper = shallow( + <FormFileUpload + instanceId={ 1 } + blocks={ [] } + recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } + > + My Upload Button + </FormFileUpload> + ); + + const iconButton = wrapper.find( 'ForwardRef(IconButton)' ); + const input = wrapper.find( 'input' ); + expect( iconButton.prop( 'children' ) ).toBe( 'My Upload Button' ); + expect( input.prop( 'style' ).display ).toBe( 'none' ); + } ); +} ); diff --git a/gb-src/packages/components/src/form-toggle/README.md b/gb-src/packages/components/src/form-toggle/README.md new file mode 100644 index 0000000000000..f247a2a12d1a6 --- /dev/null +++ b/gb-src/packages/components/src/form-toggle/README.md @@ -0,0 +1,93 @@ +# FormToggle + +FormToggle switches a single setting on or off. + +![On and off FormToggles. The top toggle is on, while the bottom toggle is off.](https://wordpress.org/gutenberg/files/2019/01/Toggle.jpg) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +#### When to use toggles + +Use toggles when you want users to: + +- Switch a single option on or off. +- Immediately activate or deactivate something. + +![FormToggle used for a “fixed background” setting](https://wordpress.org/gutenberg/files/2019/01/Toggle-Do.jpg) + +**Do** +Use toggles to switch an option on or off. + +![Radio used for a “fixed background” setting](https://wordpress.org/gutenberg/files/2019/01/Toggle-Dont.jpg) + +**Don’t** +Don’t use radio buttons for settings that toggle on and off. + +Toggles are preferred when the user is not expecting to submit data, as is the case with checkboxes and radio buttons. + +#### State + +When the user slides a toggle thumb (1) to the other side of the track (2) and the state of the toggle changes, it’s been successfully toggled. + +![Diagram showing FormToggle states](https://wordpress.org/gutenberg/files/2019/01/Toggle-Diagram.jpg) + +#### Text label + +Toggles should have clear inline labels so users know exactly what option the toggle controls, and whether the option is enabled or disabled. + +Do not include any text (e.g. “on” or “off”) within the toggle element itself. The toggle alone should be sufficient to communicate the state. + +### Behavior + +When a user switches a toggle, its corresponding action takes effect immediately. + +## Development guidelines + +### Usage + +```jsx +import { FormToggle } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyFormToggle = withState( { + checked: true, +} )( ( { checked, setState } ) => ( + <FormToggle + checked={ checked } + onChange={ () => setState( state => ( { checked: ! state.checked } ) ) } + /> +) ); +``` + +### Props + +The component accepts the following props: + +#### checked + +If checked is true the toggle will be checked. If checked is false the toggle will be unchecked. +If no value is passed the toggle will be unchecked. + +- Type: `Boolean` +- Required: No + +#### onChange + +A function that receives the checked state (boolean) as input. + +- Type: `function` +- Required: Yes + +## Related components + +- To select one option from a set, and you want to show them all the available options at once, use the `Radio` component. +- To select one or more items from a set, use the `CheckboxControl` component. + diff --git a/gb-src/packages/components/src/form-toggle/index.js b/gb-src/packages/components/src/form-toggle/index.js new file mode 100644 index 0000000000000..1b6c8275e74ed --- /dev/null +++ b/gb-src/packages/components/src/form-toggle/index.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { Path, SVG } from '../primitives'; + +function FormToggle( { className, checked, id, onChange = noop, ...props } ) { + const wrapperClasses = classnames( + 'components-form-toggle', + className, + { 'is-checked': checked }, + ); + + return ( + <span className={ wrapperClasses }> + <input + className="components-form-toggle__input" + id={ id } + type="checkbox" + checked={ checked } + onChange={ onChange } + { ...props } + /> + <span className="components-form-toggle__track"></span> + <span className="components-form-toggle__thumb"></span> + { checked ? + <SVG className="components-form-toggle__on" width="2" height="6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 6"><Path d="M0 0h2v6H0z" /></SVG> : + <SVG className="components-form-toggle__off" width="6" height="6" aria-hidden="true" role="img" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 6"><Path d="M3 1.5c.8 0 1.5.7 1.5 1.5S3.8 4.5 3 4.5 1.5 3.8 1.5 3 2.2 1.5 3 1.5M3 0C1.3 0 0 1.3 0 3s1.3 3 3 3 3-1.3 3-3-1.3-3-3-3z" /></SVG> + } + </span> + ); +} + +export default FormToggle; diff --git a/gb-src/packages/components/src/form-toggle/style.scss b/gb-src/packages/components/src/form-toggle/style.scss new file mode 100644 index 0000000000000..ebe43dc70441a --- /dev/null +++ b/gb-src/packages/components/src/form-toggle/style.scss @@ -0,0 +1,144 @@ +$toggle-width: 36px; +$toggle-height: 18px; +$toggle-border-width: 2px; + +.components-form-toggle { + position: relative; + display: inline-block; + + // On/Off icon indicators. + .components-form-toggle__on, + .components-form-toggle__off { + position: absolute; + top: $toggle-border-width * 3; + box-sizing: border-box; + } + + .components-form-toggle__off { + color: $dark-gray-300; + fill: currentColor; + right: $toggle-border-width * 3; + } + + .components-form-toggle__on { + left: $toggle-border-width * 3 + 2px; // Indent 2px extra because icon is thinner. + } + + // Unchecked state. + .components-form-toggle__track { + content: ""; + display: inline-block; + box-sizing: border-box; + vertical-align: top; + background-color: $white; + border: $toggle-border-width solid $dark-gray-300; + width: $toggle-width; + height: $toggle-height; + border-radius: $toggle-height / 2; + transition: 0.2s background ease; + @include reduce-motion("transition"); + } + + .components-form-toggle__thumb { + display: block; + position: absolute; + box-sizing: border-box; + top: $toggle-border-width * 2; + left: $toggle-border-width * 2; + width: $toggle-height - ($toggle-border-width * 4); + height: $toggle-height - ($toggle-border-width * 4); + border-radius: 50%; + transition: 0.1s transform ease; + @include reduce-motion("transition"); + background-color: $dark-gray-300; + border: 5px solid $dark-gray-300; // Has explicit border to act as a fill in Windows High Contrast Mode. + } + + &:hover { + .components-form-toggle__track { + border: $toggle-border-width solid $dark-gray-500; + } + + .components-form-toggle__thumb { + background-color: $dark-gray-500; + border: 5px solid $dark-gray-300; // Has explicit border to act as a fill in Windows High Contrast Mode. + } + + .components-form-toggle__off { + color: $dark-gray-500; + } + } + + // Checked state. + &.is-checked .components-form-toggle__track { + background-color: theme(toggle); + border: $toggle-border-width solid theme(toggle); + border: #{ $toggle-height / 2 } solid transparent; // Expand the border to fake a solid in Windows High Contrast Mode. + } + + &__input:focus + .components-form-toggle__track { + @include switch-style__focus-active(); + } + + &.is-checked { + .components-form-toggle__thumb { + background-color: $white; + border-width: 0; // Zero out the border color to make the thumb invisible in Windows High Contrast Mode. + transform: translateX($toggle-width - ($toggle-border-width * 4) - ($toggle-height - ($toggle-border-width * 4))); + } + + &::before { + background-color: theme(toggle); + border: $toggle-border-width solid theme(toggle); + } + } + + // Disabled state: + .components-disabled & { + opacity: 0.3; + } +} + +// This needs specificity to override inherited checkbox styles. +.components-form-toggle input.components-form-toggle__input[type="checkbox"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + margin: 0; + padding: 0; + z-index: z-index(".components-form-toggle__input"); + + // This overrides a border style that is inherited from parent checkbox styles. + border: none; + &:checked { + background: none; + } + + // Don't show custom checkbox checkmark. + &::before { + content: ""; + } +} + +// Ensure on indicator works in normal and Windows high contrast mode both. +.components-form-toggle .components-form-toggle__on { + // Outlines show up in windows high contrast mode. + outline: $border-width solid transparent; + outline-offset: -1px; + + // This colors the indicator black, then inverts it for normal mode. + border: $border-width solid $black; + filter: invert(100%) contrast(500%); // This makes the icon white for normal mode, and it makes it dark blue in Windows High Contrast Mode. +} + +@supports (-ms-high-contrast-adjust: auto) { + // Edge stacks outlines on top of the SVG itself, and when showing them in high contrast mode it means they get inverted again. + // Therefore, show a different style for the on indicator only in Edge and IE11. + .components-form-toggle .components-form-toggle__on { + filter: none; + border: $border-width solid $white; + } +} diff --git a/gb-src/packages/components/src/form-toggle/test/index.js b/gb-src/packages/components/src/form-toggle/test/index.js new file mode 100644 index 0000000000000..14cae967bfb78 --- /dev/null +++ b/gb-src/packages/components/src/form-toggle/test/index.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import FormToggle from '../'; + +describe( 'FormToggle', () => { + describe( 'basic rendering', () => { + it( 'should render a span element with an unchecked checkbox', () => { + const formToggle = shallow( <FormToggle /> ); + expect( formToggle.hasClass( 'components-form-toggle' ) ).toBe( true ); + expect( formToggle.hasClass( 'is-checked' ) ).toBe( false ); + expect( formToggle.type() ).toBe( 'span' ); + } ); + + it( 'should render a checked checkbox and change the accessibility text to On when providing checked prop', () => { + const formToggle = shallow( <FormToggle checked /> ); + expect( formToggle.hasClass( 'is-checked' ) ).toBe( true ); + expect( formToggle.find( '.components-form-toggle__input' ).prop( 'checked' ) ).toBe( true ); + } ); + + it( 'should render with an additional className', () => { + const formToggle = shallow( <FormToggle className="testing" /> ); + expect( formToggle.hasClass( 'testing' ) ).toBe( true ); + } ); + + it( 'should render an id prop for the input checkbox', () => { + // Disabled because of our rule restricting literal IDs, preferring + // `withInstanceId`. In this case, it's fine to use literal IDs. + // eslint-disable-next-line no-restricted-syntax + const formToggle = shallow( <FormToggle id="test" /> ); + expect( formToggle.find( '.components-form-toggle__input' ).prop( 'id' ) ).toBe( 'test' ); + } ); + + it( 'should render a checkbox with a noop onChange', () => { + const formToggle = shallow( <FormToggle /> ); + const checkBox = formToggle.prop( 'children' ).find( ( child ) => 'input' === child.type && 'checkbox' === child.props.type ); + expect( checkBox.props.onChange ).toBe( noop ); + } ); + + it( 'should render a checkbox with a user-provided onChange', () => { + const testFunction = ( event ) => event; + const formToggle = shallow( <FormToggle onChange={ testFunction } /> ); + const checkBox = formToggle.prop( 'children' ).find( ( child ) => 'input' === child.type && 'checkbox' === child.props.type ); + expect( checkBox.props.onChange ).toBe( testFunction ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/form-token-field/README.md b/gb-src/packages/components/src/form-token-field/README.md new file mode 100644 index 0000000000000..8d4a984ea11dd --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/README.md @@ -0,0 +1,71 @@ +# Form Token Field + +A `FormTokenField` is a field similar to the tags and categories fields in the interim editor chrome, or the "to" field in Mail on OS X. Tokens can be entered by typing them or selecting them from a list of suggested tokens. + +Up to one hundred suggestions that match what the user has typed so far will be shown from which the user can pick from (auto-complete). Tokens are separated by the "," character. Suggestions can be selected with the up or down arrows and added with the tab or enter key. + +The `value` property is handled in a manner similar to controlled form components. See [Forms](http://facebook.github.io/react/docs/forms.html) in the React Documentation for more information. + +## Keyboard Accessibility + +- `left arrow` - if input field is empty, move insertion point before previous token +- `right arrow` - if input field is empty, move insertion point after next token +- `up arrow` - select previous suggestion +- `down arrow` - select next suggestion +- `tab` / `enter` - if suggestion selected, insert suggestion as a new token; otherwise, insert value typed into input as new token +- `comma` - insert value typed into input as new token + +## Properties + +- `value` - An array of strings or objects to display as tokens in the field. If objects are present in the array, they **must** have a property of `value`. Here is an example object that could be passed in as a value: + +```javascript +{ + value: '(string) The value of the token.', + status: "(string) One of 'error', 'validating', or 'success'. Applies styles to token." + title: '(string) If not falsey, will add a title to the token.', + onMouserEnter: '(function) Function to call when onMouseEnter event triggered on token.' + onMouseLeave: '(function) Function to call when onMouseLeave is triggered on token.' +} +``` +- `displayTransform` - Function to call to transform tokens for display. (In + the editor, this is needed to decode HTML entities embedded in tags - + otherwise entities like `&` in tag names are double-encoded like `&`, + once by the REST API and once by React). +- `saveTransform` - Function to call to transform tokens for saving. The + default is to trim the token value. This function is also applied when + matching suggestions against the current value so that matching works + correctly with leading or trailing spaces. (In the editor, this is needed to + remove leading and trailing spaces from tag names, like wp-admin does. + Otherwise the REST API won't save them.) +- `onChange` - Function to call when the tokens have changed. An array of new + tokens is passed to the callback. +- `onInputChange` - Function to call when the users types in the input field. It can be used to trigger autocomplete requests. +- `onFocus` - Function to call when the TokenField has been focused on. The event is passed to the callback. Useful for analytics. +- `suggestions` - An array of strings to present to the user as suggested + tokens. +- `maxSuggestions` - The maximum number of suggestions to display at a time. +- `tokenizeOnSpace` - If true, will add a token when `TokenField` is focused and `space` is pressed. +- `isBorderless` - When true, renders tokens as without a background. +- `maxLength` - If passed, `TokenField` will disable ability to add new tokens once number of tokens is greater than or equal to `maxLength`. +- `disabled` - When true, tokens are not able to be added or removed. +- `placeholder` - If passed, the `TokenField` input will show a placeholder string if no value tokens are present. + +## Usage + +```jsx +import { FormTokenField } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyFormTokenField = withState( { + tokens: [], + suggestions: [ 'Africa', 'America', 'Antarctica', 'Asia', 'Europe', 'Oceania' ], +} )( ( { tokens, suggestions, setState } ) => ( + <FormTokenField + value={ tokens } + suggestions={ suggestions } + onChange={ tokens => setState( { tokens } ) } + placeholder="Type a continent" + /> +) ); +``` diff --git a/gb-src/packages/components/src/form-token-field/index.js b/gb-src/packages/components/src/form-token-field/index.js new file mode 100644 index 0000000000000..bdb8871f0a5aa --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/index.js @@ -0,0 +1,632 @@ +/** + * External dependencies + */ +import { last, take, clone, uniq, map, difference, each, identity, some } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; +import { BACKSPACE, ENTER, UP, DOWN, LEFT, RIGHT, SPACE, DELETE, ESCAPE } from '@wordpress/keycodes'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import Token from './token'; +import TokenInput from './token-input'; +import SuggestionsList from './suggestions-list'; +import withSpokenMessages from '../higher-order/with-spoken-messages'; + +const initialState = { + incompleteTokenValue: '', + inputOffsetFromEnd: 0, + isActive: false, + isExpanded: false, + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, +}; + +class FormTokenField extends Component { + constructor() { + super( ...arguments ); + this.state = initialState; + this.onKeyDown = this.onKeyDown.bind( this ); + this.onKeyPress = this.onKeyPress.bind( this ); + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.deleteTokenBeforeInput = this.deleteTokenBeforeInput.bind( this ); + this.deleteTokenAfterInput = this.deleteTokenAfterInput.bind( this ); + this.addCurrentToken = this.addCurrentToken.bind( this ); + this.onContainerTouched = this.onContainerTouched.bind( this ); + this.renderToken = this.renderToken.bind( this ); + this.onTokenClickRemove = this.onTokenClickRemove.bind( this ); + this.onSuggestionHovered = this.onSuggestionHovered.bind( this ); + this.onSuggestionSelected = this.onSuggestionSelected.bind( this ); + this.onInputChange = this.onInputChange.bind( this ); + this.bindInput = this.bindInput.bind( this ); + this.bindTokensAndInput = this.bindTokensAndInput.bind( this ); + this.updateSuggestions = this.updateSuggestions.bind( this ); + } + + componentDidUpdate( prevProps ) { + // Make sure to focus the input when the isActive state is true. + if ( this.state.isActive && ! this.input.hasFocus() ) { + this.input.focus(); + } + + const { suggestions, value } = this.props; + const suggestionsDidUpdate = ! isShallowEqual( suggestions, prevProps.suggestions ); + if ( suggestionsDidUpdate || value !== prevProps.value ) { + this.updateSuggestions( suggestionsDidUpdate ); + } + } + + static getDerivedStateFromProps( props, state ) { + if ( ! props.disabled || ! state.isActive ) { + return null; + } + + return { + isActive: false, + incompleteTokenValue: '', + }; + } + + bindInput( ref ) { + this.input = ref; + } + + bindTokensAndInput( ref ) { + this.tokensAndInput = ref; + } + + onFocus( event ) { + // If focus is on the input or on the container, set the isActive state to true. + if ( this.input.hasFocus() || event.target === this.tokensAndInput ) { + this.setState( { isActive: true } ); + } else { + /* + * Otherwise, focus is on one of the token "remove" buttons and we + * set the isActive state to false to prevent the input to be + * re-focused, see componentDidUpdate(). + */ + this.setState( { isActive: false } ); + } + + if ( 'function' === typeof this.props.onFocus ) { + this.props.onFocus( event ); + } + } + + onBlur() { + if ( this.inputHasValidValue() ) { + this.setState( { isActive: false } ); + } else { + this.setState( initialState ); + } + } + + onKeyDown( event ) { + let preventDefault = false; + + switch ( event.keyCode ) { + case BACKSPACE: + preventDefault = this.handleDeleteKey( this.deleteTokenBeforeInput ); + break; + case ENTER: + preventDefault = this.addCurrentToken(); + break; + case LEFT: + preventDefault = this.handleLeftArrowKey(); + break; + case UP: + preventDefault = this.handleUpArrowKey(); + break; + case RIGHT: + preventDefault = this.handleRightArrowKey(); + break; + case DOWN: + preventDefault = this.handleDownArrowKey(); + break; + case DELETE: + preventDefault = this.handleDeleteKey( this.deleteTokenAfterInput ); + break; + case SPACE: + if ( this.props.tokenizeOnSpace ) { + preventDefault = this.addCurrentToken(); + } + break; + case ESCAPE: + preventDefault = this.handleEscapeKey( event ); + event.stopPropagation(); + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + } + + onKeyPress( event ) { + let preventDefault = false; + switch ( event.charCode ) { + case 44: // comma + preventDefault = this.handleCommaKey(); + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + } + + onContainerTouched( event ) { + // Prevent clicking/touching the tokensAndInput container from blurring + // the input and adding the current token. + if ( event.target === this.tokensAndInput && this.state.isActive ) { + event.preventDefault(); + } + } + + onTokenClickRemove( event ) { + this.deleteToken( event.value ); + this.input.focus(); + } + + onSuggestionHovered( suggestion ) { + const index = this.getMatchingSuggestions().indexOf( suggestion ); + + if ( index >= 0 ) { + this.setState( { + selectedSuggestionIndex: index, + selectedSuggestionScroll: false, + } ); + } + } + + onSuggestionSelected( suggestion ) { + this.addNewToken( suggestion ); + } + + onInputChange( event ) { + const text = event.value; + const separator = this.props.tokenizeOnSpace ? /[ ,\t]+/ : /[,\t]+/; + const items = text.split( separator ); + const tokenValue = last( items ) || ''; + + if ( items.length > 1 ) { + this.addNewTokens( items.slice( 0, -1 ) ); + } + + this.setState( { incompleteTokenValue: tokenValue }, this.updateSuggestions ); + + this.props.onInputChange( tokenValue ); + } + + handleDeleteKey( deleteToken ) { + let preventDefault = false; + if ( this.input.hasFocus() && this.isInputEmpty() ) { + deleteToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleLeftArrowKey() { + let preventDefault = false; + if ( this.isInputEmpty() ) { + this.moveInputBeforePreviousToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleRightArrowKey() { + let preventDefault = false; + if ( this.isInputEmpty() ) { + this.moveInputAfterNextToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleUpArrowKey() { + this.setState( ( state, props ) => ( { + selectedSuggestionIndex: ( + ( state.selectedSuggestionIndex === 0 ? this.getMatchingSuggestions( + state.incompleteTokenValue, + props.suggestions, + props.value, + props.maxSuggestions, + props.saveTransform + ).length : state.selectedSuggestionIndex ) - 1 + ), + selectedSuggestionScroll: true, + } ) ); + + return true; // preventDefault + } + + handleDownArrowKey() { + this.setState( ( state, props ) => ( { + selectedSuggestionIndex: ( + ( state.selectedSuggestionIndex + 1 ) % this.getMatchingSuggestions( + state.incompleteTokenValue, + props.suggestions, + props.value, + props.maxSuggestions, + props.saveTransform + ).length + ), + selectedSuggestionScroll: true, + } ) ); + + return true; // preventDefault + } + + handleEscapeKey( event ) { + this.setState( { + incompleteTokenValue: event.target.value, + isExpanded: false, + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, + } ); + return true; // preventDefault + } + + handleCommaKey() { + if ( this.inputHasValidValue() ) { + this.addNewToken( this.state.incompleteTokenValue ); + } + + return true; // preventDefault + } + + moveInputToIndex( index ) { + this.setState( ( state, props ) => ( { + inputOffsetFromEnd: props.value.length - Math.max( index, -1 ) - 1, + } ) ); + } + + moveInputBeforePreviousToken() { + this.setState( ( state, props ) => ( { + inputOffsetFromEnd: Math.min( state.inputOffsetFromEnd + 1, props.value.length ), + } ) ); + } + + moveInputAfterNextToken() { + this.setState( ( state ) => ( { + inputOffsetFromEnd: Math.max( state.inputOffsetFromEnd - 1, 0 ), + } ) ); + } + + deleteTokenBeforeInput() { + const index = this.getIndexOfInput() - 1; + + if ( index > -1 ) { + this.deleteToken( this.props.value[ index ] ); + } + } + + deleteTokenAfterInput() { + const index = this.getIndexOfInput(); + + if ( index < this.props.value.length ) { + this.deleteToken( this.props.value[ index ] ); + // update input offset since it's the offset from the last token + this.moveInputToIndex( index ); + } + } + + addCurrentToken() { + let preventDefault = false; + const selectedSuggestion = this.getSelectedSuggestion(); + + if ( selectedSuggestion ) { + this.addNewToken( selectedSuggestion ); + preventDefault = true; + } else if ( this.inputHasValidValue() ) { + this.addNewToken( this.state.incompleteTokenValue ); + preventDefault = true; + } + + return preventDefault; + } + + addNewTokens( tokens ) { + const tokensToAdd = uniq( + tokens + .map( this.props.saveTransform ) + .filter( Boolean ) + .filter( ( token ) => ! this.valueContainsToken( token ) ) + ); + + if ( tokensToAdd.length > 0 ) { + const newValue = clone( this.props.value ); + newValue.splice.apply( + newValue, + [ this.getIndexOfInput(), 0 ].concat( tokensToAdd ) + ); + this.props.onChange( newValue ); + } + } + + addNewToken( token ) { + this.addNewTokens( [ token ] ); + this.props.speak( this.props.messages.added, 'assertive' ); + + this.setState( { + incompleteTokenValue: '', + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, + isExpanded: false, + } ); + + if ( this.state.isActive ) { + this.input.focus(); + } + } + + deleteToken( token ) { + const newTokens = this.props.value.filter( ( item ) => { + return this.getTokenValue( item ) !== this.getTokenValue( token ); + } ); + this.props.onChange( newTokens ); + this.props.speak( this.props.messages.removed, 'assertive' ); + } + + getTokenValue( token ) { + if ( 'object' === typeof token ) { + return token.value; + } + + return token; + } + + getMatchingSuggestions( + searchValue = this.state.incompleteTokenValue, + suggestions = this.props.suggestions, + value = this.props.value, + maxSuggestions = this.props.maxSuggestions, + saveTransform = this.props.saveTransform, + ) { + let match = saveTransform( searchValue ); + const startsWithMatch = []; + const containsMatch = []; + + if ( match.length === 0 ) { + suggestions = difference( suggestions, value ); + } else { + match = match.toLocaleLowerCase(); + + each( suggestions, ( suggestion ) => { + const index = suggestion.toLocaleLowerCase().indexOf( match ); + if ( value.indexOf( suggestion ) === -1 ) { + if ( index === 0 ) { + startsWithMatch.push( suggestion ); + } else if ( index > 0 ) { + containsMatch.push( suggestion ); + } + } + } ); + + suggestions = startsWithMatch.concat( containsMatch ); + } + + return take( suggestions, maxSuggestions ); + } + + getSelectedSuggestion() { + if ( this.state.selectedSuggestionIndex !== -1 ) { + return this.getMatchingSuggestions()[ this.state.selectedSuggestionIndex ]; + } + } + + valueContainsToken( token ) { + return some( this.props.value, ( item ) => { + return this.getTokenValue( token ) === this.getTokenValue( item ); + } ); + } + + getIndexOfInput() { + return this.props.value.length - this.state.inputOffsetFromEnd; + } + + isInputEmpty() { + return this.state.incompleteTokenValue.length === 0; + } + + inputHasValidValue() { + return this.props.saveTransform( this.state.incompleteTokenValue ).length > 0; + } + + updateSuggestions( resetSelectedSuggestion = true ) { + const { incompleteTokenValue } = this.state; + + const inputHasMinimumChars = incompleteTokenValue.trim().length > 1; + const matchingSuggestions = this.getMatchingSuggestions( incompleteTokenValue ); + const hasMatchingSuggestions = matchingSuggestions.length > 0; + + const newState = { + isExpanded: inputHasMinimumChars && hasMatchingSuggestions, + }; + if ( resetSelectedSuggestion ) { + newState.selectedSuggestionIndex = -1; + newState.selectedSuggestionScroll = false; + } + + this.setState( newState ); + + if ( inputHasMinimumChars ) { + const { debouncedSpeak } = this.props; + + const message = hasMatchingSuggestions ? + sprintf( _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + matchingSuggestions.length + ), matchingSuggestions.length ) : + __( 'No results.' ); + + debouncedSpeak( message, 'assertive' ); + } + } + + renderTokensAndInput() { + const components = map( this.props.value, this.renderToken ); + components.splice( this.getIndexOfInput(), 0, this.renderInput() ); + + return components; + } + + renderToken( token, index, tokens ) { + const value = this.getTokenValue( token ); + const status = token.status ? token.status : undefined; + const termPosition = index + 1; + const termsCount = tokens.length; + + return ( + <Token + key={ 'token-' + value } + value={ value } + status={ status } + title={ token.title } + displayTransform={ this.props.displayTransform } + onClickRemove={ this.onTokenClickRemove } + isBorderless={ token.isBorderless || this.props.isBorderless } + onMouseEnter={ token.onMouseEnter } + onMouseLeave={ token.onMouseLeave } + disabled={ 'error' !== status && this.props.disabled } + messages={ this.props.messages } + termsCount={ termsCount } + termPosition={ termPosition } + /> + ); + } + + renderInput() { + const { autoCapitalize, autoComplete, maxLength, value, instanceId } = this.props; + + let props = { + instanceId, + autoCapitalize, + autoComplete, + ref: this.bindInput, + key: 'input', + disabled: this.props.disabled, + value: this.state.incompleteTokenValue, + onBlur: this.onBlur, + isExpanded: this.state.isExpanded, + selectedSuggestionIndex: this.state.selectedSuggestionIndex, + }; + + if ( ! ( maxLength && value.length >= maxLength ) ) { + props = { ...props, onChange: this.onInputChange }; + } + + return ( + <TokenInput { ...props } /> + ); + } + + render() { + const { + disabled, + label = __( 'Add item' ), + instanceId, + className, + } = this.props; + const { isExpanded } = this.state; + const classes = classnames( className, 'components-form-token-field__input-container', { + 'is-active': this.state.isActive, + 'is-disabled': disabled, + } ); + + let tokenFieldProps = { + className: 'components-form-token-field', + tabIndex: '-1', + }; + const matchingSuggestions = this.getMatchingSuggestions(); + + if ( ! disabled ) { + tokenFieldProps = Object.assign( {}, tokenFieldProps, { + onKeyDown: this.onKeyDown, + onKeyPress: this.onKeyPress, + onFocus: this.onFocus, + } ); + } + + // Disable reason: There is no appropriate role which describes the + // input container intended accessible usability. + // TODO: Refactor click detection to use blur to stop propagation. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div { ...tokenFieldProps }> + <label + htmlFor={ `components-form-token-input-${ instanceId }` } + className="components-form-token-field__label" + > + { label } + </label> + <div ref={ this.bindTokensAndInput } + className={ classes } + tabIndex="-1" + onMouseDown={ this.onContainerTouched } + onTouchStart={ this.onContainerTouched } + > + { this.renderTokensAndInput() } + { isExpanded && ( + <SuggestionsList + instanceId={ instanceId } + match={ this.props.saveTransform( this.state.incompleteTokenValue ) } + displayTransform={ this.props.displayTransform } + suggestions={ matchingSuggestions } + selectedIndex={ this.state.selectedSuggestionIndex } + scrollIntoView={ this.state.selectedSuggestionScroll } + onHover={ this.onSuggestionHovered } + onSelect={ this.onSuggestionSelected } + /> + ) } + </div> + <p id={ `components-form-token-suggestions-howto-${ instanceId }` } className="components-form-token-field__help"> + { this.props.tokenizeOnSpace ? + __( 'Separate with commas, spaces, or the Enter key.' ) : + __( 'Separate with commas or the Enter key.' ) + } + </p> + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +FormTokenField.defaultProps = { + suggestions: Object.freeze( [] ), + maxSuggestions: 100, + value: Object.freeze( [] ), + displayTransform: identity, + saveTransform: ( token ) => token.trim(), + onChange: () => {}, + onInputChange: () => {}, + isBorderless: false, + disabled: false, + tokenizeOnSpace: false, + messages: { + added: __( 'Item added.' ), + removed: __( 'Item removed.' ), + remove: __( 'Remove item' ), + }, +}; + +export default withSpokenMessages( withInstanceId( FormTokenField ) ); diff --git a/gb-src/packages/components/src/form-token-field/style.scss b/gb-src/packages/components/src/form-token-field/style.scss new file mode 100644 index 0000000000000..35f9569f268b1 --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/style.scss @@ -0,0 +1,190 @@ +.components-form-token-field__input-container { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + width: 100%; + margin: 0 0 $grid-size 0; + padding: $grid-size-small; + background-color: $white; + border: $border-width solid $light-gray-700; + color: $dark-gray-700; + cursor: text; + + @include input-style__neutral(); + + &.is-disabled { + background: $light-gray-500; + border-color: $light-gray-700; + } + + &.is-active { + @include input-style__focus(); + } + + // Token input + input[type="text"].components-form-token-field__input { + display: inline-block; + width: 100%; + max-width: 100%; + margin: 2px 0 2px 8px; + padding: 0; + min-height: 24px; + background: inherit; + border: 0; + color: $dark-gray-800; + box-shadow: none; + + &:focus, + .components-form-token-field.is-active & { + outline: none; + box-shadow: none; + } + } + .components-form-token-field__token + input[type="text"].components-form-token-field__input { + width: auto; + } +} + +.components-form-token-field__label { + display: inline-block; + margin-bottom: $grid-size-small; +} + +.components-form-token-field__help { + font-style: italic; +} + +// Tokens +.components-form-token-field__token { + font-size: $default-font-size; + display: flex; + margin: 2px 4px 2px 0; + color: $dark-gray-700; + overflow: hidden; + + &.is-success { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + background: $alert-green; + } + } + + &.is-error { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + background: $alert-red; + } + } + + &.is-validating { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + color: $dark-gray-500; + } + } + + &.is-borderless { + position: relative; + padding: 0 16px 0 0; + + .components-form-token-field__token-text { + background: transparent; + color: theme(secondary); + } + + .components-form-token-field__remove-token { + background: transparent; + color: $dark-gray-500; + position: absolute; + top: 1px; + right: 0; + } + + &.is-success { + .components-form-token-field__token-text { + color: $alert-green; + } + } + + &.is-error { + .components-form-token-field__token-text { + color: $alert-red; + border-radius: 4px 0 0 4px; + padding: 0 4px 0 6px; + } + } + + &.is-validating { + .components-form-token-field__token-text { + color: $dark-gray-800; + } + } + } + + &.is-disabled { + .components-form-token-field__remove-token { + cursor: default; + } + } +} + +.components-form-token-field__token-text, +.components-form-token-field__remove-token.components-icon-button { + display: inline-block; + line-height: 24px; + background: $light-gray-500; + transition: all 0.2s cubic-bezier(0.4, 1, 0.4, 1); + @include reduce-motion; +} + +.components-form-token-field__token-text { + border-radius: 12px 0 0 12px; + padding: 0 4px 0 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.components-form-token-field__remove-token.components-icon-button { + cursor: pointer; + border-radius: 0 12px 12px 0; + padding: 0 2px; + color: $dark-gray-500; + line-height: 10px; + overflow: initial; + + &:hover { + color: $dark-gray-700; + } +} + +// Suggestion list +.components-form-token-field__suggestions-list { + flex: 1 0 100%; + min-width: 100%; + max-height: 9em; + overflow-y: scroll; + transition: all 0.15s ease-in-out; + @include reduce-motion("transition"); + list-style: none; + border-top: $border-width solid $dark-gray-300; + margin: $grid-size-small (-$grid-size-small) (-$grid-size-small); + padding-top: 3px; +} + +.components-form-token-field__suggestion { + color: $dark-gray-500; + display: block; + font-size: $default-font-size; + padding: 4px 8px; + cursor: pointer; + + &.is-selected { + background: $blue-dark-900; + color: $white; + } +} + +.components-form-token-field__suggestion-match { + text-decoration: underline; +} diff --git a/gb-src/packages/components/src/form-token-field/suggestions-list.js b/gb-src/packages/components/src/form-token-field/suggestions-list.js new file mode 100644 index 0000000000000..2d7e64f84be7d --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/suggestions-list.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { map } from 'lodash'; +import scrollIntoView from 'dom-scroll-into-view'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSafeTimeout } from '@wordpress/compose'; + +class SuggestionsList extends Component { + constructor() { + super( ...arguments ); + this.handleMouseDown = this.handleMouseDown.bind( this ); + this.bindList = this.bindList.bind( this ); + } + + componentDidUpdate() { + // only have to worry about scrolling selected suggestion into view + // when already expanded + if ( this.props.selectedIndex > -1 && this.props.scrollIntoView ) { + this.scrollingIntoView = true; + scrollIntoView( this.list.children[ this.props.selectedIndex ], this.list, { + onlyScrollIfNeeded: true, + } ); + + this.props.setTimeout( () => { + this.scrollingIntoView = false; + }, 100 ); + } + } + + bindList( ref ) { + this.list = ref; + } + + handleHover( suggestion ) { + return () => { + if ( ! this.scrollingIntoView ) { + this.props.onHover( suggestion ); + } + }; + } + + handleClick( suggestion ) { + return () => { + this.props.onSelect( suggestion ); + }; + } + + handleMouseDown( e ) { + // By preventing default here, we will not lose focus of <input> when clicking a suggestion + e.preventDefault(); + } + + computeSuggestionMatch( suggestion ) { + const match = this.props.displayTransform( this.props.match || '' ).toLocaleLowerCase(); + if ( match.length === 0 ) { + return null; + } + + suggestion = this.props.displayTransform( suggestion ); + const indexOfMatch = suggestion.toLocaleLowerCase().indexOf( match ); + + return { + suggestionBeforeMatch: suggestion.substring( 0, indexOfMatch ), + suggestionMatch: suggestion.substring( indexOfMatch, indexOfMatch + match.length ), + suggestionAfterMatch: suggestion.substring( indexOfMatch + match.length ), + }; + } + + render() { + // We set `tabIndex` here because otherwise Firefox sets focus on this + // div when tabbing off of the input in `TokenField` -- not really sure + // why, since usually a div isn't focusable by default + // TODO does this still apply now that it's a <ul> and not a <div>? + return ( + <ul + ref={ this.bindList } + className="components-form-token-field__suggestions-list" + id={ `components-form-token-suggestions-${ this.props.instanceId }` } + role="listbox" + > + { + map( this.props.suggestions, ( suggestion, index ) => { + const match = this.computeSuggestionMatch( suggestion ); + const classeName = classnames( 'components-form-token-field__suggestion', { + 'is-selected': index === this.props.selectedIndex, + } ); + + /* eslint-disable jsx-a11y/click-events-have-key-events */ + return ( + <li + id={ `components-form-token-suggestions-${ this.props.instanceId }-${ index }` } + role="option" + className={ classeName } + key={ suggestion } + onMouseDown={ this.handleMouseDown } + onClick={ this.handleClick( suggestion ) } + onMouseEnter={ this.handleHover( suggestion ) } + aria-selected={ index === this.props.selectedIndex } + > + { match ? + ( + <span aria-label={ this.props.displayTransform( suggestion ) }> + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ) : + this.props.displayTransform( suggestion ) + } + </li> + ); + /* eslint-enable jsx-a11y/click-events-have-key-events */ + } ) + } + </ul> + ); + } +} + +SuggestionsList.defaultProps = { + match: '', + onHover: () => {}, + onSelect: () => {}, + suggestions: Object.freeze( [] ), +}; + +export default withSafeTimeout( SuggestionsList ); diff --git a/gb-src/packages/components/src/form-token-field/test/index.js b/gb-src/packages/components/src/form-token-field/test/index.js new file mode 100644 index 0000000000000..3bfcf6e5baf0d --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/test/index.js @@ -0,0 +1,319 @@ +/** + * External dependencies + */ +import { filter, map } from 'lodash'; +import TestUtils from 'react-dom/test-utils'; +import ReactDOM from 'react-dom'; + +/** + * Internal dependencies + */ +import fixtures from './lib/fixtures'; +import TokenFieldWrapper from './lib/token-field-wrapper'; +import TokenInput from '../token-input'; + +/** + * Module variables + */ +const keyCodes = { + backspace: 8, + tab: 9, + enter: 13, + leftArrow: 37, + upArrow: 38, + rightArrow: 39, + downArrow: 40, + delete: 46, + comma: 188, +}; + +const charCodes = { + comma: 44, +}; + +describe( 'FormTokenField', function() { + let wrapper, wrapperElement, textInputElement, textInputComponent; + + function setText( text ) { + TestUtils.Simulate.change( + textInputElement(), + { + target: { + value: text, + }, + } + ); + } + + function sendKeyDown( keyCode, shiftKey ) { + TestUtils.Simulate.keyDown( + wrapperElement(), + { + keyCode, + shiftKey: ! ! shiftKey, + } + ); + } + + function sendKeyPress( charCode ) { + TestUtils.Simulate.keyPress( + wrapperElement(), + { charCode } + ); + } + + function getTokensHTML() { + const textNodes = wrapperElement().querySelectorAll( '.components-form-token-field__token-text span[aria-hidden]' ); + return map( textNodes, ( node ) => node.innerHTML ); + } + + function getSuggestionsText( selector ) { + const suggestionNodes = wrapperElement().querySelectorAll( selector || '.components-form-token-field__suggestion' ); + + return map( suggestionNodes, getSuggestionNodeText ); + } + + function getSuggestionNodeText( node ) { + if ( ! node.querySelector( 'span' ) ) { + return node.outerHTML; + } + + // This suggestion is part of a partial match; return up to three + // sections of the suggestion (before match, match, and after + // match) + const div = document.createElement( 'div' ); + div.innerHTML = node.querySelector( 'span' ).outerHTML; + return map( + filter( + div.firstChild.childNodes, + ( childNode ) => childNode.nodeType !== window.Node.COMMENT_NODE + ), + ( childNode ) => childNode.textContent + ); + } + + function getSelectedSuggestion() { + const selectedSuggestions = getSuggestionsText( '.components-form-token-field__suggestion.is-selected' ); + + return selectedSuggestions[ 0 ] || null; + } + + beforeEach( function() { + wrapper = TestUtils.renderIntoDocument( + <TokenFieldWrapper />, + ); + /* eslint-disable react/no-find-dom-node */ + wrapperElement = () => ReactDOM.findDOMNode( wrapper ); + textInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-form-token-field__input' + ); + textInputComponent = () => TestUtils.findRenderedComponentWithType( + wrapper, + TokenInput + ); + /* eslint-enable react/no-find-dom-node */ + TestUtils.Simulate.focus( textInputElement() ); + } ); + + describe( 'displaying tokens', function() { + it( 'should render default tokens', function() { + wrapper.setState( { + isExpanded: true, + } ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should display tokens with escaped special characters properly', function() { + wrapper.setState( { + tokens: fixtures.specialTokens.textEscaped, + isExpanded: true, + } ); + expect( getTokensHTML() ).toEqual( fixtures.specialTokens.htmlEscaped ); + } ); + + it( 'should display tokens with special characters properly', function() { + // This test is not as realistic as the previous one: if a WP site + // contains tag names with special characters, the API will always + // return the tag names already escaped. However, this is still + // worth testing, so we can be sure that token values with + // dangerous characters in them don't have these characters carried + // through unescaped to the HTML. + wrapper.setState( { + tokens: fixtures.specialTokens.textUnescaped, + isExpanded: true, + } ); + expect( getTokensHTML() ).toEqual( fixtures.specialTokens.htmlUnescaped ); + } ); + } ); + + describe( 'suggestions', function() { + it( 'should not render suggestions unless we type at least two characters', function() { + wrapper.setState( { + isExpanded: true, + } ); + expect( getSuggestionsText() ).toEqual( [] ); + setText( 'th' ); + expect( getSuggestionsText() ).toEqual( fixtures.matchingSuggestions.th ); + } ); + + it( 'should remove already added tags from suggestions', function() { + wrapper.setState( { + tokens: Object.freeze( [ 'of', 'and' ] ), + } ); + expect( getSuggestionsText() ).not.toEqual( getTokensHTML() ); + } ); + + it( 'suggestions that begin with match are boosted', function() { + wrapper.setState( { + isExpanded: true, + } ); + setText( 'so' ); + expect( getSuggestionsText() ).toEqual( fixtures.matchingSuggestions.so ); + } ); + + it( 'should match against the unescaped values of suggestions with special characters', function() { + wrapper.setState( { + tokenSuggestions: fixtures.specialSuggestions.textUnescaped, + isExpanded: true, + } ); + setText( '& S' ); + expect( getSuggestionsText() ).toEqual( fixtures.specialSuggestions.matchAmpersandUnescaped ); + } ); + + it( 'should match against the unescaped values of suggestions with special characters (including spaces)', function() { + wrapper.setState( { + tokenSuggestions: fixtures.specialSuggestions.textUnescaped, + isExpanded: true, + } ); + setText( 's &' ); + expect( getSuggestionsText() ).toEqual( fixtures.specialSuggestions.matchAmpersandSequence ); + } ); + + it( 'should not match against the escaped values of suggestions with special characters', function() { + setText( 'amp' ); + wrapper.setState( { + tokenSuggestions: fixtures.specialSuggestions.textUnescaped, + isExpanded: true, + } ); + expect( getSuggestionsText() ).toEqual( fixtures.specialSuggestions.matchAmpersandEscaped ); + } ); + + it( 'should match suggestions even with trailing spaces', function() { + wrapper.setState( { + isExpanded: true, + } ); + setText( ' at ' ); + expect( getSuggestionsText() ).toEqual( fixtures.matchingSuggestions.at ); + } ); + + it( 'should manage the selected suggestion based on both keyboard and mouse events', function() { + wrapper.setState( { + isExpanded: true, + } ); + setText( 'th' ); + expect( getSuggestionsText() ).toEqual( fixtures.matchingSuggestions.th ); + expect( getSelectedSuggestion() ).toBe( null ); + sendKeyDown( keyCodes.downArrow ); // 'the' + expect( getSelectedSuggestion() ).toEqual( [ 'th', 'e' ] ); + sendKeyDown( keyCodes.downArrow ); // 'that' + expect( getSelectedSuggestion() ).toEqual( [ 'th', 'at' ] ); + + const hoverSuggestion = wrapperElement() + .querySelectorAll( '.components-form-token-field__suggestion' )[ 3 ]; // 'with' + expect( getSuggestionNodeText( hoverSuggestion ) ).toEqual( [ 'wi', 'th' ] ); + + // before sending a hover event, we need to wait for + // SuggestionList#_scrollingIntoView to become false + jest.runTimersToTime( 100 ); + + TestUtils.Simulate.mouseEnter( hoverSuggestion ); + expect( getSelectedSuggestion() ).toEqual( [ 'wi', 'th' ] ); + sendKeyDown( keyCodes.upArrow ); + expect( getSelectedSuggestion() ).toEqual( [ 'th', 'is' ] ); + sendKeyDown( keyCodes.upArrow ); + expect( getSelectedSuggestion() ).toEqual( [ 'th', 'at' ] ); + TestUtils.Simulate.click( hoverSuggestion ); + expect( getSelectedSuggestion() ).toBe( null ); + expect( getTokensHTML() ).toEqual( [ 'foo', 'bar', 'with' ] ); + } ); + + it( 'should re-render when suggestions prop has changed', function() { + wrapper.setState( { + tokenSuggestions: [], + isExpanded: true, + } ); + expect( getSuggestionsText() ).toEqual( [] ); + setText( 'so' ); + expect( getSuggestionsText() ).toEqual( [] ); + + wrapper.setState( { + tokenSuggestions: fixtures.specialSuggestions.default, + } ); + expect( getSuggestionsText() ).toEqual( fixtures.matchingSuggestions.so ); + + wrapper.setState( { + tokenSuggestions: [], + } ); + expect( getSuggestionsText() ).toEqual( [] ); + } ); + } ); + + describe( 'adding tokens', function() { + it( 'should not allow adding blank tokens with Tab', function() { + sendKeyDown( keyCodes.tab ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should not allow adding whitespace tokens with Tab', function() { + setText( ' ' ); + sendKeyDown( keyCodes.tab ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should add a token when Enter pressed', function() { + setText( 'baz' ); + sendKeyDown( keyCodes.enter ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] ); + const textNode = textInputComponent(); + expect( textNode.props.value ).toBe( '' ); + } ); + + it( 'should not allow adding blank tokens with Enter', function() { + sendKeyDown( keyCodes.enter ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should not allow adding whitespace tokens with Enter', function() { + setText( ' ' ); + sendKeyDown( keyCodes.enter ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should not allow adding whitespace tokens with comma', function() { + setText( ' ' ); + sendKeyPress( charCodes.comma ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] ); + } ); + + it( 'should add a token when comma pressed', function() { + setText( 'baz' ); + sendKeyPress( charCodes.comma ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] ); + } ); + + it( 'should trim token values when adding', function() { + setText( ' baz ' ); + sendKeyDown( keyCodes.enter ); + expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] ); + } ); + } ); + + describe( 'removing tokens', function() { + it( 'should remove tokens when X icon clicked', function() { + const forClickNode = wrapperElement().querySelector( '.components-form-token-field__remove-token' ).firstChild; + TestUtils.Simulate.click( forClickNode ); + expect( wrapper.state.tokens ).toEqual( [ 'bar' ] ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/form-token-field/test/lib/fixtures.js b/gb-src/packages/components/src/form-token-field/test/lib/fixtures.js new file mode 100644 index 0000000000000..6811275377788 --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/test/lib/fixtures.js @@ -0,0 +1,43 @@ +export default { + specialTokens: { + textEscaped: [ 'a b', 'i <3 tags', '1&2&3&4' ], + htmlEscaped: [ 'a   b', 'i <3 tags', '1&2&3&4' ], + textUnescaped: [ 'a b', 'i <3 tags', '1&2&3&4' ], + htmlUnescaped: [ 'a   b', 'i <3 tags', '1&2&3&4' ], + }, + specialSuggestions: { + default: [ + 'the', 'of', 'and', 'to', 'a', 'in', 'for', 'is', 'on', 'that', 'by', 'this', 'with', 'i', 'you', 'it', + 'not', 'or', 'be', 'are', 'from', 'at', 'as', 'your', 'all', 'have', 'new', 'more', 'an', 'was', 'we', + 'associate', 'snake', 'pipes', 'sound', + ], + textEscaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ], + textUnescaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ], + matchAmpersandUnescaped: [ + [ 'Tags ', '& S', 'tuff' ], + [ 'Tags ', '& S', 'tuff 2' ], + ], + matchAmpersandSequence: [ + [ 'Tag', 's &', ' Stuff' ], + [ 'Tag', 's &', ' Stuff 2' ], + ], + matchAmpersandEscaped: [], + }, + matchingSuggestions: { + th: [ + [ 'th', 'e' ], + [ 'th', 'at' ], + [ 'th', 'is' ], + [ 'wi', 'th' ], + ], + so: [ + [ 'so', 'und' ], + [ 'as', 'so', 'ciate' ], + ], + at: [ + [ 'at' ], + [ 'th', 'at' ], + [ 'associ', 'at', 'e' ], + ], + }, +}; diff --git a/gb-src/packages/components/src/form-token-field/test/lib/token-field-wrapper.js b/gb-src/packages/components/src/form-token-field/test/lib/token-field-wrapper.js new file mode 100644 index 0000000000000..12dc95581b433 --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/test/lib/token-field-wrapper.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { unescape } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import fixtures from './fixtures'; +import TokenField from '../../'; + +const { specialSuggestions: { default: suggestions } } = fixtures; + +function unescapeAndFormatSpaces( str ) { + const nbsp = String.fromCharCode( 160 ); + return unescape( str ).replace( / /g, nbsp ); +} + +class TokenFieldWrapper extends Component { + constructor() { + super( ...arguments ); + this.state = { + tokenSuggestions: suggestions, + tokens: Object.freeze( [ 'foo', 'bar' ] ), + isExpanded: false, + }; + this.onTokensChange = this.onTokensChange.bind( this ); + } + + render() { + return ( + <TokenField + suggestions={ this.state.isExpanded ? this.state.tokenSuggestions : null } + value={ this.state.tokens } + displayTransform={ unescapeAndFormatSpaces } + onChange={ this.onTokensChange } + /> + ); + } + + onTokensChange( value ) { + this.setState( { tokens: value } ); + } +} + +module.exports = TokenFieldWrapper; diff --git a/gb-src/packages/components/src/form-token-field/token-input.js b/gb-src/packages/components/src/form-token-field/token-input.js new file mode 100644 index 0000000000000..3f2fd4e2dec5e --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/token-input.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +class TokenInput extends Component { + constructor() { + super( ...arguments ); + this.onChange = this.onChange.bind( this ); + this.bindInput = this.bindInput.bind( this ); + } + + focus() { + this.input.focus(); + } + + hasFocus() { + return this.input === document.activeElement; + } + + bindInput( ref ) { + this.input = ref; + } + + onChange( event ) { + this.props.onChange( { + value: event.target.value, + } ); + } + + render() { + const { value, isExpanded, instanceId, selectedSuggestionIndex, ...props } = this.props; + const size = value.length + 1; + + return ( + <input + ref={ this.bindInput } + id={ `components-form-token-input-${ instanceId }` } + type="text" + { ...props } + value={ value } + onChange={ this.onChange } + size={ size } + className="components-form-token-field__input" + role="combobox" + aria-expanded={ isExpanded } + aria-autocomplete="list" + aria-owns={ isExpanded ? `components-form-token-suggestions-${ instanceId }` : undefined } + aria-activedescendant={ selectedSuggestionIndex !== -1 ? `components-form-token-suggestions-${ instanceId }-${ selectedSuggestionIndex }` : undefined } + aria-describedby={ `components-form-token-suggestions-howto-${ instanceId }` } + /> + ); + } +} + +export default TokenInput; diff --git a/gb-src/packages/components/src/form-token-field/token.js b/gb-src/packages/components/src/form-token-field/token.js new file mode 100644 index 0000000000000..93af65a064fd8 --- /dev/null +++ b/gb-src/packages/components/src/form-token-field/token.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withInstanceId } from '@wordpress/compose'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; + +function Token( { + value, + status, + title, + displayTransform, + isBorderless = false, + disabled = false, + onClickRemove = noop, + onMouseEnter, + onMouseLeave, + messages, + termPosition, + termsCount, + instanceId, +} ) { + const tokenClasses = classnames( 'components-form-token-field__token', { + 'is-error': 'error' === status, + 'is-success': 'success' === status, + 'is-validating': 'validating' === status, + 'is-borderless': isBorderless, + 'is-disabled': disabled, + } ); + + const onClick = () => onClickRemove( { value } ); + + const transformedValue = displayTransform( value ); + const termPositionAndCount = sprintf( + /* translators: 1: term name, 2: term position in a set of terms, 3: total term set count. */ + __( '%1$s (%2$s of %3$s)' ), + transformedValue, + termPosition, + termsCount + ); + + return ( + <span + className={ tokenClasses } + onMouseEnter={ onMouseEnter } + onMouseLeave={ onMouseLeave } + title={ title } + > + <span + className="components-form-token-field__token-text" + id={ `components-form-token-field__token-text-${ instanceId }` } + > + <span className="screen-reader-text">{ termPositionAndCount }</span> + <span aria-hidden="true">{ transformedValue }</span> + </span> + + <IconButton + className="components-form-token-field__remove-token" + icon="dismiss" + onClick={ ! disabled && onClick } + label={ messages.remove } + aria-describedby={ `components-form-token-field__token-text-${ instanceId }` } + /> + </span> + ); +} + +export default withInstanceId( Token ); diff --git a/gb-src/packages/components/src/higher-order/README.md b/gb-src/packages/components/src/higher-order/README.md new file mode 100644 index 0000000000000..3d780e75ba6b1 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/README.md @@ -0,0 +1,6 @@ +Higher Order Components +======================= + +This directory includes a library of generic Higher Order React Components. + +[Learn more about Higher Order Components](https://facebook.github.io/react/docs/higher-order-components.html) diff --git a/gb-src/packages/components/src/higher-order/navigate-regions/README.md b/gb-src/packages/components/src/higher-order/navigate-regions/README.md new file mode 100644 index 0000000000000..e741403379deb --- /dev/null +++ b/gb-src/packages/components/src/higher-order/navigate-regions/README.md @@ -0,0 +1,19 @@ +# navigateRegions + +`navigateRegions` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) adding keyboard navigation to switch between the different DOM elements marked as "regions" (role="region"). These regions should be focusable (By adding a tabIndex attribute for example). For better accessibility, these elements must be properly labelled to briefly describe the purpose of the content in the region. For more details, see "ARIA landmarks" in the [WAI-ARIA specification](https://www.w3.org/TR/wai-aria/). + +## Example: + +```jsx +import { navigateRegions } from '@wordpress/components'; + +const MyComponentWithNavigateRegions = navigateRegions( + () => ( + <div> + <div role="region" tabIndex="-1" aria-label="Header">Header</div> + <div role="region" tabIndex="-1" aria-label="Content">Content</div> + <div role="region" tabIndex="-1" aria-label="Sidebar">Sidebar</div> + </div> + ) +); +``` diff --git a/gb-src/packages/components/src/higher-order/navigate-regions/index.js b/gb-src/packages/components/src/higher-order/navigate-regions/index.js new file mode 100644 index 0000000000000..c716e2d1ea9f1 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/navigate-regions/index.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { rawShortcut } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import KeyboardShortcuts from '../../keyboard-shortcuts'; + +export default createHigherOrderComponent( + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + this.bindContainer = this.bindContainer.bind( this ); + this.focusNextRegion = this.focusRegion.bind( this, 1 ); + this.focusPreviousRegion = this.focusRegion.bind( this, -1 ); + this.onClick = this.onClick.bind( this ); + this.state = { + isFocusingRegions: false, + }; + } + + bindContainer( ref ) { + this.container = ref; + } + + focusRegion( offset ) { + const regions = [ ...this.container.querySelectorAll( '[role="region"]' ) ]; + if ( ! regions.length ) { + return; + } + let nextRegion = regions[ 0 ]; + const selectedIndex = regions.indexOf( document.activeElement ); + if ( selectedIndex !== -1 ) { + let nextIndex = selectedIndex + offset; + nextIndex = nextIndex === -1 ? regions.length - 1 : nextIndex; + nextIndex = nextIndex === regions.length ? 0 : nextIndex; + nextRegion = regions[ nextIndex ]; + } + + nextRegion.focus(); + this.setState( { isFocusingRegions: true } ); + } + + onClick() { + this.setState( { isFocusingRegions: false } ); + } + + render() { + const className = classnames( 'components-navigate-regions', { + 'is-focusing-regions': this.state.isFocusingRegions, + } ); + + // Disable reason: Clicking the editor should dismiss the regions focus style + /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + return ( + <div ref={ this.bindContainer } className={ className } onClick={ this.onClick }> + <KeyboardShortcuts + bindGlobal + shortcuts={ { + 'ctrl+`': this.focusNextRegion, + [ rawShortcut.access( 'n' ) ]: this.focusNextRegion, + 'ctrl+shift+`': this.focusPreviousRegion, + [ rawShortcut.access( 'p' ) ]: this.focusPreviousRegion, + } } + /> + <WrappedComponent { ...this.props } /> + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + } + }; + }, 'navigateRegions' +); diff --git a/gb-src/packages/components/src/higher-order/navigate-regions/style.scss b/gb-src/packages/components/src/higher-order/navigate-regions/style.scss new file mode 100644 index 0000000000000..4653a08ee3862 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/navigate-regions/style.scss @@ -0,0 +1,27 @@ +.components-navigate-regions.is-focusing-regions [role="region"] { + // For browsers that don't support outline-offset (IE11). + &:focus::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + pointer-events: none; + outline: 4px solid transparent; // Shown in Windows High Contrast mode. + box-shadow: inset 0 0 0 4px $blue-medium-400; + } + + @supports ( outline-offset: 1px ) { + &:focus::after { + content: none; + } + + &:focus { + outline-style: solid; + outline-color: $blue-medium-400; + outline-width: 4px; + outline-offset: -4px; + } + } +} diff --git a/gb-src/packages/components/src/higher-order/with-constrained-tabbing/README.md b/gb-src/packages/components/src/higher-order/with-constrained-tabbing/README.md new file mode 100644 index 0000000000000..143d3e857ddf7 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-constrained-tabbing/README.md @@ -0,0 +1,41 @@ +# withConstrainedTabbing + +`withConstrainedTabbing` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) adding the ability to constrain keyboard navigation with the Tab key within a component. For accessibility reasons, some UI components need to constrain Tab navigation, for example modal dialogs or similar UI. Use of this component is recommended only in cases where a way to navigate away from the wrapped component is implemented by other means, usually by pressing the Escape key or using a specific UI control, e.g. a "Close" button. + +## Usage + +Wrap your original component with `withConstrainedTabbing`. + +```jsx +import { withConstrainedTabbing, TextControl, Button } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const ConstrainedTabbing = withConstrainedTabbing( ( { children } ) => children ); + +const MyComponentWithConstrainedTabbing = withState( { + isConstrainedTabbing: false, +} )( ( { isConstrainedTabbing, setState } ) => { + let form = ( + <form> + <TextControl label="Input 1" onChange={ () => {} } /> + <TextControl label="Input 2" onChange={ () => {} } /> + </form> + ); + if ( isConstrainedTabbing ) { + form = <ConstrainedTabbing>{ form }</ConstrainedTabbing>; + } + + const toggleConstrain = () => { + setState( ( state ) => ( { isConstrainedTabbing: ! state.isConstrainedTabbing } ) ); + }; + + return ( + <div> + { form } + <Button isDefault onClick={ toggleConstrain }> + { isConstrainedTabbing ? 'Disable' : 'Enable' } constrain tabbing + </Button> + </div> + ); +} ); +``` diff --git a/gb-src/packages/components/src/higher-order/with-constrained-tabbing/index.js b/gb-src/packages/components/src/higher-order/with-constrained-tabbing/index.js new file mode 100644 index 0000000000000..75745cc9d0242 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-constrained-tabbing/index.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { TAB } from '@wordpress/keycodes'; +import { focus } from '@wordpress/dom'; + +const withConstrainedTabbing = createHigherOrderComponent( + ( WrappedComponent ) => class extends Component { + constructor() { + super( ...arguments ); + + this.focusContainRef = createRef(); + this.handleTabBehaviour = this.handleTabBehaviour.bind( this ); + } + + handleTabBehaviour( event ) { + if ( event.keyCode !== TAB ) { + return; + } + + const tabbables = focus.tabbable.find( this.focusContainRef.current ); + if ( ! tabbables.length ) { + return; + } + const firstTabbable = tabbables[ 0 ]; + const lastTabbable = tabbables[ tabbables.length - 1 ]; + + if ( event.shiftKey && event.target === firstTabbable ) { + event.preventDefault(); + lastTabbable.focus(); + } else if ( ! event.shiftKey && event.target === lastTabbable ) { + event.preventDefault(); + firstTabbable.focus(); + /* + * When pressing Tab and none of the tabbables has focus, the keydown + * event happens on the wrapper div: move focus on the first tabbable. + */ + } else if ( ! tabbables.includes( event.target ) ) { + event.preventDefault(); + firstTabbable.focus(); + } + } + + render() { + // Disable reason: this component is non-interactive, but must capture + // events from the wrapped component to determine when the Tab key is used. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + onKeyDown={ this.handleTabBehaviour } + ref={ this.focusContainRef } + tabIndex="-1" + > + <WrappedComponent { ...this.props } /> + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } + }, + 'withConstrainedTabbing' +); + +export default withConstrainedTabbing; diff --git a/gb-src/packages/components/src/higher-order/with-fallback-styles/README.md b/gb-src/packages/components/src/higher-order/with-fallback-styles/README.md new file mode 100644 index 0000000000000..49ed7678613bc --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-fallback-styles/README.md @@ -0,0 +1,25 @@ +# withFallbackStyles + +## Usage + +```jsx +import { withFallbackStyles, Button } from '@wordpress/components'; + +const { getComputedStyle } = window; + +const MyComponentWithFallbackStyles = withFallbackStyles( ( node, ownProps ) => { + const buttonNode = node.querySelector( 'button' ); + return { + fallbackBackgroundColor: getComputedStyle( buttonNode ).backgroundColor, + fallbackTextColor: getComputedStyle( buttonNode ).color, + }; +} )( + ( { fallbackTextColor, fallbackBackgroundColor } ) => ( + <div> + <Button isPrimary>My button</Button> + <div>Text color: { fallbackTextColor }</div> + <div>Background color: { fallbackBackgroundColor }</div> + </div> + ) +); +``` diff --git a/gb-src/packages/components/src/higher-order/with-fallback-styles/index.js b/gb-src/packages/components/src/higher-order/with-fallback-styles/index.js new file mode 100644 index 0000000000000..0771f567bd474 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-fallback-styles/index.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { every, isEqual } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +export default ( mapNodeToProps ) => createHigherOrderComponent( + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + this.nodeRef = this.props.node; + this.state = { + fallbackStyles: undefined, + grabStylesCompleted: false, + }; + + this.bindRef = this.bindRef.bind( this ); + } + + bindRef( node ) { + if ( ! node ) { + return; + } + this.nodeRef = node; + } + + componentDidMount() { + this.grabFallbackStyles(); + } + + componentDidUpdate() { + this.grabFallbackStyles(); + } + + grabFallbackStyles() { + const { grabStylesCompleted, fallbackStyles } = this.state; + if ( this.nodeRef && ! grabStylesCompleted ) { + const newFallbackStyles = mapNodeToProps( this.nodeRef, this.props ); + if ( ! isEqual( newFallbackStyles, fallbackStyles ) ) { + this.setState( { + fallbackStyles: newFallbackStyles, + grabStylesCompleted: !! every( newFallbackStyles ), + } ); + } + } + } + + render() { + const wrappedComponent = <WrappedComponent { ...this.props } { ...this.state.fallbackStyles } />; + return this.props.node ? wrappedComponent : <div ref={ this.bindRef }> { wrappedComponent } </div>; + } + }; + }, + 'withFallbackStyles' +); diff --git a/gb-src/packages/components/src/higher-order/with-filters/README.md b/gb-src/packages/components/src/higher-order/with-filters/README.md new file mode 100644 index 0000000000000..a5522fd8a14b4 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-filters/README.md @@ -0,0 +1,66 @@ +# withFilters + +`withFilters` is a part of [Native Gutenberg Extensibility](https://github.com/WordPress/gutenberg/issues/3330). It is also a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html). + +Wrapping a component with `withFilters` provides a filtering capability controlled externally by the `hookName`. + +## Usage + +```jsx +import { withFilters } from '@wordpress/components'; +import { addFilter } from '@wordpress/hooks'; + +const MyComponent = ( { title } ) => <h1>{ title }</h1>; + +const ComponentToAppend = () => <div>Appended component</div>; + +function withComponentAppended( FilteredComponent ) { + return ( props ) => ( + <> + <FilteredComponent { ...props } /> + <ComponentToAppend /> + </> + ); +} + +addFilter( + 'MyHookName', + 'my-plugin/with-component-appended', + withComponentAppended +); + +const MyComponentWithFilters = withFilters( 'MyHookName' )( MyComponent ); +``` + +`withFilters` expects a string argument which provides a hook name. It returns a function which can then be used in composing your component. The hook name allows plugin developers to customize or completely override the component passed to this higher-order component using `wp.hooks.addFilter` method. + +It is also possible to override props by implementing a higher-order component which works as follows: + +```jsx +import { withFilters } from '@wordpress/components'; +import { addFilter } from '@wordpress/hooks'; + +const MyComponent = ( { hint, title } ) => ( + <> + <h1>{ title }</h1> + <p>{ hint }</p> + </> +); + +function withHintOverridden( FilteredComponent ) { + return ( props ) => ( + <FilteredComponent + { ...props } + hint="Overridden hint" + /> + ); + } + +addFilter( + 'MyHookName', + 'my-plugin/with-hint-overridden', + withHintOverridden +); + +const MyComponentWithFilters = withFilters( 'MyHookName' )( MyComponent ); +``` diff --git a/gb-src/packages/components/src/higher-order/with-filters/index.js b/gb-src/packages/components/src/higher-order/with-filters/index.js new file mode 100644 index 0000000000000..e44a41cee35af --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-filters/index.js @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { debounce, without } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { addAction, applyFilters, removeAction } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +const ANIMATION_FRAME_PERIOD = 16; + +/** + * Creates a higher-order component which adds filtering capability to the + * wrapped component. Filters get applied when the original component is about + * to be mounted. When a filter is added or removed that matches the hook name, + * the wrapped component re-renders. + * + * @param {string} hookName Hook name exposed to be used by filters. + * + * @return {Function} Higher-order component factory. + */ +export default function withFilters( hookName ) { + return createHigherOrderComponent( ( OriginalComponent ) => { + const namespace = 'core/with-filters/' + hookName; + + /** + * The component definition with current filters applied. Each instance + * reuse this shared reference as an optimization to avoid excessive + * calls to `applyFilters` when many instances exist. + * + * @type {?Component} + */ + let FilteredComponent; + + /** + * Initializes the FilteredComponent variable once, if not already + * assigned. Subsequent calls are effectively a noop. + */ + function ensureFilteredComponent() { + if ( FilteredComponent === undefined ) { + FilteredComponent = applyFilters( hookName, OriginalComponent ); + } + } + + class FilteredComponentRenderer extends Component { + constructor() { + super( ...arguments ); + + ensureFilteredComponent(); + } + + componentDidMount() { + FilteredComponentRenderer.instances.push( this ); + + // If there were previously no mounted instances for components + // filtered on this hook, add the hook handler. + if ( FilteredComponentRenderer.instances.length === 1 ) { + addAction( 'hookRemoved', namespace, onHooksUpdated ); + addAction( 'hookAdded', namespace, onHooksUpdated ); + } + } + + componentWillUnmount() { + FilteredComponentRenderer.instances = without( + FilteredComponentRenderer.instances, + this + ); + + // If this was the last of the mounted components filtered on + // this hook, remove the hook handler. + if ( FilteredComponentRenderer.instances.length === 0 ) { + removeAction( 'hookRemoved', namespace ); + removeAction( 'hookAdded', namespace ); + } + } + + render() { + return <FilteredComponent { ...this.props } />; + } + } + + FilteredComponentRenderer.instances = []; + + /** + * Updates the FilteredComponent definition, forcing a render for each + * mounted instance. This occurs a maximum of once per animation frame. + */ + const throttledForceUpdate = debounce( () => { + // Recreate the filtered component, only after delay so that it's + // computed once, even if many filters added. + FilteredComponent = applyFilters( hookName, OriginalComponent ); + + // Force each instance to render. + FilteredComponentRenderer.instances.forEach( ( instance ) => { + instance.forceUpdate(); + } ); + }, ANIMATION_FRAME_PERIOD ); + + /** + * When a filter is added or removed for the matching hook name, each + * mounted instance should re-render with the new filters having been + * applied to the original component. + * + * @param {string} updatedHookName Name of the hook that was updated. + */ + function onHooksUpdated( updatedHookName ) { + if ( updatedHookName === hookName ) { + throttledForceUpdate(); + } + } + + return FilteredComponentRenderer; + }, 'withFilters' ); +} diff --git a/gb-src/packages/components/src/higher-order/with-filters/test/index.js b/gb-src/packages/components/src/higher-order/with-filters/test/index.js new file mode 100644 index 0000000000000..68aecd9685e80 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-filters/test/index.js @@ -0,0 +1,256 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import TestUtils from 'react-dom/test-utils'; +import ReactDOM from 'react-dom'; + +/** + * WordPress dependencies + */ +import { addFilter, removeAllFilters, removeFilter } from '@wordpress/hooks'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withFilters from '..'; + +const assertExpectedHtml = ( wrapper, expectedHTML ) => { + // eslint-disable-next-line react/no-find-dom-node + const element = ReactDOM.findDOMNode( wrapper ); + expect( element.outerHTML ).toBe( expectedHTML ); +}; + +// this is needed because TestUtils does not accept a stateless component. +// anything run through a HOC ends up as a stateless component. +const getTestComponent = ( WrappedComponent ) => { + class TestComponent extends Component { + render() { + return <WrappedComponent { ...this.props } />; + } + } + return <TestComponent />; +}; + +describe( 'withFilters', () => { + let shallowWrapper, wrapper; + + const hookName = 'EnhancedComponent'; + const MyComponent = () => <div>My component</div>; + + afterEach( () => { + if ( wrapper ) { + // eslint-disable-next-line react/no-find-dom-node + ReactDOM.unmountComponentAtNode( ReactDOM.findDOMNode( wrapper ).parentNode ); + } + if ( shallowWrapper ) { + shallowWrapper.unmount(); + } + removeAllFilters( hookName ); + } ); + + it( 'should display original component when no filters applied', () => { + const EnhancedComponent = withFilters( hookName )( MyComponent ); + + shallowWrapper = shallow( <EnhancedComponent /> ); + + expect( shallowWrapper.html() ).toBe( '<div>My component</div>' ); + } ); + + it( 'should display a component overridden by the filter', () => { + const OverriddenComponent = () => <div>Overridden component</div>; + addFilter( + 'EnhancedComponent', + 'test/enhanced-component-override', + () => OverriddenComponent + ); + const EnhancedComponent = withFilters( hookName )( MyComponent ); + + shallowWrapper = shallow( <EnhancedComponent /> ); + + expect( shallowWrapper.html() ).toBe( '<div>Overridden component</div>' ); + } ); + + it( 'should display two components composed by the filter', () => { + const ComposedComponent = () => <div>Composed component</div>; + addFilter( + hookName, + 'test/enhanced-component-compose', + ( FilteredComponent ) => () => ( + <div> + <FilteredComponent /> + <ComposedComponent /> + </div> + ) + ); + const EnhancedComponent = withFilters( hookName )( MyComponent ); + + shallowWrapper = shallow( <EnhancedComponent /> ); + + expect( shallowWrapper.html() ).toBe( '<div><div>My component</div><div>Composed component</div></div>' ); + } ); + + it( 'should not re-render component when new filter added before component was mounted', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return <div>Spied component</div>; + }; + addFilter( + hookName, + 'test/enhanced-component-spy-1', + ( FilteredComponent ) => () => ( + <blockquote> + <FilteredComponent /> + </blockquote> + ), + ); + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + + wrapper = TestUtils.renderIntoDocument( getTestComponent( EnhancedComponent ) ); + + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + assertExpectedHtml( + wrapper, + '<blockquote><div>Spied component</div></blockquote>' + ); + } ); + + it( 'should re-render component once when new filter added after component was mounted', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return <div>Spied component</div>; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + + wrapper = TestUtils.renderIntoDocument( getTestComponent( EnhancedComponent ) ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy-1', + ( FilteredComponent ) => () => ( + <blockquote> + <FilteredComponent /> + </blockquote> + ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + assertExpectedHtml( + wrapper, + '<blockquote><div>Spied component</div></blockquote>' + ); + } ); + + it( 'should re-render component once when two filters added in the same animation frame', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return <div>Spied component</div>; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + wrapper = TestUtils.renderIntoDocument( getTestComponent( EnhancedComponent ) ); + + spy.mockClear(); + + addFilter( + hookName, + 'test/enhanced-component-spy-1', + ( FilteredComponent ) => () => ( + <blockquote> + <FilteredComponent /> + </blockquote> + ), + ); + addFilter( + hookName, + 'test/enhanced-component-spy-2', + ( FilteredComponent ) => () => ( + <section> + <FilteredComponent /> + </section> + ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + assertExpectedHtml( + wrapper, + '<section><blockquote><div>Spied component</div></blockquote></section>' + ); + } ); + + it( 'should re-render component twice when new filter added and removed in two different animation frames', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return <div>Spied component</div>; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + wrapper = TestUtils.renderIntoDocument( getTestComponent( EnhancedComponent ) ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy', + ( FilteredComponent ) => () => ( + <div> + <FilteredComponent /> + </div> + ), + ); + jest.runAllTimers(); + + removeFilter( + hookName, + 'test/enhanced-component-spy', + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 2 ); + assertExpectedHtml( + wrapper, + '<div>Spied component</div>' + ); + } ); + + it( 'should re-render both components once each when one filter added', () => { + const spy = jest.fn(); + const SpiedComponent = () => { + spy(); + return <div>Spied component</div>; + }; + const EnhancedComponent = withFilters( hookName )( SpiedComponent ); + const CombinedComponents = () => ( + <section> + <EnhancedComponent /> + <EnhancedComponent /> + </section> + ); + wrapper = TestUtils.renderIntoDocument( getTestComponent( CombinedComponents ) ); + + spy.mockClear(); + addFilter( + hookName, + 'test/enhanced-component-spy-1', + ( FilteredComponent ) => () => ( + <blockquote> + <FilteredComponent /> + </blockquote> + ), + ); + jest.runAllTimers(); + + expect( spy ).toHaveBeenCalledTimes( 2 ); + assertExpectedHtml( + wrapper, + '<section><blockquote><div>Spied component</div></blockquote><blockquote><div>Spied component</div></blockquote></section>' + ); + } ); +} ); diff --git a/gb-src/packages/components/src/higher-order/with-focus-outside/README.md b/gb-src/packages/components/src/higher-order/with-focus-outside/README.md new file mode 100644 index 0000000000000..1a8615c6b7a34 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-outside/README.md @@ -0,0 +1,32 @@ +# withFocusOutside + +`withFocusOutside` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) to enable behavior to occur when focus leaves an element. Since a `blur` event will fire in React even when focus transitions to another element in the same context, this higher-order component encapsulates the logic necessary to determine if focus has truly left the element. + +## Usage + +Wrap your original component with `withFocusOutside`, defining a `handleFocusOutside` instance method on the component class. + +__Note:__ `withFocusOutside` must only be used to wrap the `Component` class. + +```jsx +import { withFocusOutside, TextControl } from '@wordpress/components'; + +const MyComponentWithFocusOutside = withFocusOutside( + class extends React.Component { + handleFocusOutside() { + console.log( 'Focus outside' ); + } + + render() { + return ( + <div> + <TextControl onChange={ ( ) => { } }/> + <TextControl onChange={ ( ) => { } }/> + </div> + ); + } + } +); +``` + +In the above example, the `handleFocusOutside` function is only called if focus leaves the element, and not if transitioning focus between the two inputs. diff --git a/gb-src/packages/components/src/higher-order/with-focus-outside/index.js b/gb-src/packages/components/src/higher-order/with-focus-outside/index.js new file mode 100644 index 0000000000000..5f178b4a34df2 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-outside/index.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Input types which are classified as button types, for use in considering + * whether element is a (focus-normalized) button. + * + * @type {string[]} + */ +const INPUT_BUTTON_TYPES = [ + 'button', + 'submit', +]; + +/** + * Returns true if the given element is a button element subject to focus + * normalization, or false otherwise. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {Element} element Element to test. + * + * @return {boolean} Whether element is a button. + */ +function isFocusNormalizedButton( element ) { + switch ( element.nodeName ) { + case 'A': + case 'BUTTON': + return true; + + case 'INPUT': + return includes( INPUT_BUTTON_TYPES, element.type ); + } + + return false; +} + +export default createHigherOrderComponent( + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + + this.bindNode = this.bindNode.bind( this ); + this.cancelBlurCheck = this.cancelBlurCheck.bind( this ); + this.queueBlurCheck = this.queueBlurCheck.bind( this ); + this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this ); + } + + componentWillUnmount() { + this.cancelBlurCheck(); + } + + bindNode( node ) { + if ( node ) { + this.node = node; + } else { + delete this.node; + this.cancelBlurCheck(); + } + } + + queueBlurCheck( event ) { + // React does not allow using an event reference asynchronously + // due to recycling behavior, except when explicitly persisted. + event.persist(); + + // Skip blur check if clicking button. See `normalizeButtonFocus`. + if ( this.preventBlurCheck ) { + return; + } + + this.blurCheckTimeout = setTimeout( () => { + // If document is not focused then focus should remain + // inside the wrapped component and therefore we cancel + // this blur event thereby leaving focus in place. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus. + if ( ! document.hasFocus() ) { + event.preventDefault(); + return; + } + if ( 'function' === typeof this.node.handleFocusOutside ) { + this.node.handleFocusOutside( event ); + } + }, 0 ); + } + + cancelBlurCheck() { + clearTimeout( this.blurCheckTimeout ); + } + + /** + * Handles a mousedown or mouseup event to respectively assign and + * unassign a flag for preventing blur check on button elements. Some + * browsers, namely Firefox and Safari, do not emit a focus event on + * button elements when clicked, while others do. The logic here + * intends to normalize this as treating click on buttons as focus. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {MouseEvent} event Event for mousedown or mouseup. + */ + normalizeButtonFocus( event ) { + const { type, target } = event; + + const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type ); + + if ( isInteractionEnd ) { + this.preventBlurCheck = false; + } else if ( isFocusNormalizedButton( target ) ) { + this.preventBlurCheck = true; + } + } + + render() { + // Disable reason: See `normalizeButtonFocus` for browser-specific + // focus event normalization. + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + onFocus={ this.cancelBlurCheck } + onMouseDown={ this.normalizeButtonFocus } + onMouseUp={ this.normalizeButtonFocus } + onTouchStart={ this.normalizeButtonFocus } + onTouchEnd={ this.normalizeButtonFocus } + onBlur={ this.queueBlurCheck } + > + <WrappedComponent + ref={ this.bindNode } + { ...this.props } /> + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } + }; + }, 'withFocusOutside' +); diff --git a/gb-src/packages/components/src/higher-order/with-focus-outside/index.native.js b/gb-src/packages/components/src/higher-order/with-focus-outside/index.native.js new file mode 100644 index 0000000000000..3efc036cdd46e --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-outside/index.native.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Input types which are classified as button types, for use in considering + * whether element is a (focus-normalized) button. + * + * @type {string[]} + */ +const INPUT_BUTTON_TYPES = [ + 'button', + 'submit', +]; + +/** + * Returns true if the given element is a button element subject to focus + * normalization, or false otherwise. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {Element} element Element to test. + * + * @return {boolean} Whether element is a button. + */ +function isFocusNormalizedButton( element ) { + switch ( element.nodeName ) { + case 'A': + case 'BUTTON': + return true; + + case 'INPUT': + return includes( INPUT_BUTTON_TYPES, element.type ); + } + + return false; +} + +export default createHigherOrderComponent( + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + + this.bindNode = this.bindNode.bind( this ); + this.cancelBlurCheck = this.cancelBlurCheck.bind( this ); + this.queueBlurCheck = this.queueBlurCheck.bind( this ); + this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this ); + } + + componentWillUnmount() { + this.cancelBlurCheck(); + } + + bindNode( node ) { + if ( node ) { + this.node = node; + } else { + delete this.node; + this.cancelBlurCheck(); + } + } + + queueBlurCheck( event ) { + // React does not allow using an event reference asynchronously + // due to recycling behavior, except when explicitly persisted. + event.persist(); + + // Skip blur check if clicking button. See `normalizeButtonFocus`. + if ( this.preventBlurCheck ) { + return; + } + + this.blurCheckTimeout = setTimeout( () => { + if ( 'function' === typeof this.node.handleFocusOutside ) { + this.node.handleFocusOutside( event ); + } + }, 0 ); + } + + cancelBlurCheck() { + clearTimeout( this.blurCheckTimeout ); + } + + /** + * Handles a mousedown or mouseup event to respectively assign and + * unassign a flag for preventing blur check on button elements. Some + * browsers, namely Firefox and Safari, do not emit a focus event on + * button elements when clicked, while others do. The logic here + * intends to normalize this as treating click on buttons as focus. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {MouseEvent} event Event for mousedown or mouseup. + */ + normalizeButtonFocus( event ) { + const { type, target } = event; + + const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type ); + + if ( isInteractionEnd ) { + this.preventBlurCheck = false; + } else if ( isFocusNormalizedButton( target ) ) { + this.preventBlurCheck = true; + } + } + + render() { + // Disable reason: See `normalizeButtonFocus` for browser-specific + // focus event normalization. + + return ( + <View + onFocus={ this.cancelBlurCheck } + onMouseDown={ this.normalizeButtonFocus } + onMouseUp={ this.normalizeButtonFocus } + onTouchStart={ this.normalizeButtonFocus } + onTouchEnd={ this.normalizeButtonFocus } + onBlur={ this.queueBlurCheck } + > + <WrappedComponent + ref={ this.bindNode } + { ...this.props } /> + </View> + ); + } + }; + }, 'withFocusOutside' +); diff --git a/gb-src/packages/components/src/higher-order/with-focus-outside/test/index.js b/gb-src/packages/components/src/higher-order/with-focus-outside/test/index.js new file mode 100644 index 0000000000000..ac47e3a57290a --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-outside/test/index.js @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import TestUtils from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withFocusOutside from '../'; +import ReactDOM from 'react-dom'; + +let wrapper, onFocusOutside; + +describe( 'withFocusOutside', () => { + let origHasFocus; + + const EnhancedComponent = withFocusOutside( + class extends Component { + handleFocusOutside() { + this.props.onFocusOutside(); + } + + render() { + return ( + <div> + <input /> + <input type="button" /> + </div> + ); + } + } + ); + + // this is needed because TestUtils does not accept a stateless component. + // anything run through a HOC ends up as a stateless component. + const getTestComponent = ( WrappedComponent, props ) => { + class TestComponent extends Component { + render() { + return <WrappedComponent { ...props } />; + } + } + return <TestComponent />; + }; + + const simulateEvent = ( event, index = 0 ) => { + const element = TestUtils.scryRenderedDOMComponentsWithTag( wrapper, 'input' ); + TestUtils.Simulate[ event ]( element[ index ] ); + }; + + beforeEach( () => { + // Mock document.hasFocus() to always be true for testing + // note: we overide this for some tests. + origHasFocus = document.hasFocus; + document.hasFocus = () => true; + + onFocusOutside = jest.fn(); + wrapper = TestUtils.renderIntoDocument( + getTestComponent( EnhancedComponent, { onFocusOutside } ) + ); + } ); + + afterEach( () => { + document.hasFocus = origHasFocus; + } ); + + it( 'should not call handler if focus shifts to element within component', () => { + simulateEvent( 'focus' ); + simulateEvent( 'blur' ); + simulateEvent( 'focus', 1 ); + + jest.runAllTimers(); + + expect( onFocusOutside ).not.toHaveBeenCalled(); + } ); + + it( 'should not call handler if focus transitions via click to button', () => { + simulateEvent( 'focus' ); + simulateEvent( 'mouseDown', 1 ); + simulateEvent( 'blur' ); + + // In most browsers, the input at index 1 would receive a focus event + // at this point, but this is not guaranteed, which is the intention of + // the normalization behavior tested here. + simulateEvent( 'mouseUp', 1 ); + + jest.runAllTimers(); + + expect( onFocusOutside ).not.toHaveBeenCalled(); + } ); + + it( 'should call handler if focus doesn’t shift to element within component', () => { + simulateEvent( 'focus' ); + simulateEvent( 'blur' ); + + jest.runAllTimers(); + + expect( onFocusOutside ).toHaveBeenCalled(); + } ); + + it( 'should not call handler if focus shifts outside the component when the document does not have focus', () => { + // Force document.hasFocus() to return false to simulate the window/document losing focus + // See https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus. + document.hasFocus = () => false; + + simulateEvent( 'focus' ); + simulateEvent( 'blur' ); + + jest.runAllTimers(); + + expect( onFocusOutside ).not.toHaveBeenCalled(); + } ); + + it( 'should cancel check when unmounting while queued', () => { + simulateEvent( 'focus' ); + simulateEvent( 'input' ); + + // eslint-disable-next-line react/no-find-dom-node + ReactDOM.unmountComponentAtNode( ReactDOM.findDOMNode( wrapper ).parentNode ); + + jest.runAllTimers(); + + expect( onFocusOutside ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/gb-src/packages/components/src/higher-order/with-focus-return/README.md b/gb-src/packages/components/src/higher-order/with-focus-return/README.md new file mode 100644 index 0000000000000..f0872f15600f3 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-return/README.md @@ -0,0 +1,87 @@ +# withFocusReturn + +`withFocusReturn` is a higher-order component used typically in scenarios of short-lived elements (modals, dropdowns) where, upon the element's unmounting, focus should be restored to the focused element which had initiated it being rendered. + +Optionally, it can be used in combination with a `FocusReturnProvider` which, when rendered toward the top of an application, will remember a history of elements focused during a session. This can provide safeguards for scenarios where one short-lived element triggers the creation of another (e.g. a dropdown menu triggering a modal display). The combined effect of `FocusReturnProvider` and `withFocusReturn` is that focus will be returned to the most recent focused element which is still present in the document. + +## Usage + +### `withFocusReturn` + +```jsx +import { withFocusReturn, TextControl, Button } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const EnhancedComponent = withFocusReturn( + () => ( + <div> + Focus will return to the previous input when this component is unmounted + <TextControl + autoFocus={ true } + onChange={ () => {} } + /> + </div> + ) +); + +const MyComponentWithFocusReturn = withState( { + text: '', +} )( ( { text, setState } ) => { + const unmount = () => { + document.activeElement.blur(); + setState( { text: '' } ); + } + + return ( + <div> + <TextControl + placeholder="Type something" + value={ text } + onChange={ ( text ) => setState( { text } ) } + /> + { text && <EnhancedComponent /> } + { text && <Button isDefault onClick={ unmount }>Unmount</Button> } + </div> + ); +} ); +``` + +`withFocusReturn` can optionally be called as a higher-order function creator. Provided an options object, a new higher-order function is returned. + +Currently, the following options are supported: + +#### `onFocusReturn` + +An optional function which allows the developer to customize the focus return behavior. A return value of `false` should be returned from this function to indicate that the default focus return behavior should be skipped. + +- Type: `Function` +- Required: No + +_Example:_ + +```jsx +function MyComponent() { + return <textarea />; +} + +const EnhancedMyComponent = withFocusReturn( { + onFocusReturn() { + document.getElementById( 'other-input' ).focus(); + return false; + }, +} )( MyComponent ); +``` + +### `FocusReturnProvider` + +```jsx +import { FocusReturnProvider } from '@wordpress/components'; + +function App() { + return ( + <FocusReturnProvider> + { /* ... */ } + </FocusReturnProvider> + ); +} +``` diff --git a/gb-src/packages/components/src/higher-order/with-focus-return/context.js b/gb-src/packages/components/src/higher-order/with-focus-return/context.js new file mode 100644 index 0000000000000..f65834f488692 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-return/context.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { uniq } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createContext } from '@wordpress/element'; + +const { Provider, Consumer } = createContext( { + focusHistory: [], +} ); + +Provider.displayName = 'FocusReturnProvider'; +Consumer.displayName = 'FocusReturnConsumer'; + +/** + * The maximum history length to capture for the focus stack. When exceeded, + * items should be shifted from the stack for each consecutive push. + * + * @type {number} + */ +const MAX_STACK_LENGTH = 100; + +class FocusReturnProvider extends Component { + constructor() { + super( ...arguments ); + + this.onFocus = this.onFocus.bind( this ); + + this.state = { + focusHistory: [], + }; + } + + onFocus( event ) { + const { focusHistory } = this.state; + + // Push the focused element to the history stack, keeping only unique + // members but preferring the _last_ occurrence of any duplicates. + // Lodash's `uniq` behavior favors the first occurrence, so the array + // is temporarily reversed prior to it being called upon. Uniqueness + // helps avoid situations where, such as in a constrained tabbing area, + // the user changes focus enough within a transient element that the + // stack may otherwise only consist of members pending destruction, at + // which point focus might have been lost. + const nextFocusHistory = uniq( + [ ...focusHistory, event.target ] + .slice( -1 * MAX_STACK_LENGTH ) + .reverse() + ).reverse(); + + this.setState( { focusHistory: nextFocusHistory } ); + } + + render() { + const { children, className } = this.props; + + return ( + <Provider value={ this.state }> + <div onFocus={ this.onFocus } className={ className }> + { children } + </div> + </Provider> + ); + } +} + +export default FocusReturnProvider; +export { Consumer }; diff --git a/gb-src/packages/components/src/higher-order/with-focus-return/index.js b/gb-src/packages/components/src/higher-order/with-focus-return/index.js new file mode 100644 index 0000000000000..2b504878bdf52 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-return/index.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { stubTrue, without } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import Provider, { Consumer } from './context'; + +/** + * Returns true if the given object is component-like. An object is component- + * like if it is an instance of wp.element.Component, or is a function. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is component-like. + */ +function isComponentLike( object ) { + return ( + object instanceof Component || + typeof object === 'function' + ); +} + +/** + * Higher Order Component used to be used to wrap disposable elements like + * sidebars, modals, dropdowns. When mounting the wrapped component, we track a + * reference to the current active element so we know where to restore focus + * when the component is unmounted. + * + * @param {(WPComponent|Object)} options The component to be enhanced with + * focus return behavior, or an object + * describing the component and the + * focus return characteristics. + * + * @return {Component} Component with the focus restauration behaviour. + */ +function withFocusReturn( options ) { + // Normalize as overloaded form `withFocusReturn( options )( Component )` + // or as `withFocusReturn( Component )`. + if ( isComponentLike( options ) ) { + const WrappedComponent = options; + return withFocusReturn( {} )( WrappedComponent ); + } + + const { onFocusReturn = stubTrue } = options; + + return function( WrappedComponent ) { + class FocusReturn extends Component { + constructor() { + super( ...arguments ); + + this.ownFocusedElements = new Set; + this.activeElementOnMount = document.activeElement; + this.setIsFocusedFalse = () => this.isFocused = false; + this.setIsFocusedTrue = ( event ) => { + this.ownFocusedElements.add( event.target ); + this.isFocused = true; + }; + } + + componentWillUnmount() { + const { + activeElementOnMount, + isFocused, + ownFocusedElements, + } = this; + + if ( ! isFocused ) { + return; + } + + // Defer to the component's own explicit focus return behavior, + // if specified. The function should return `false` to prevent + // the default behavior otherwise occurring here. This allows + // for support that the `onFocusReturn` decides to allow the + // default behavior to occur under some conditions. + if ( onFocusReturn() === false ) { + return; + } + + const stack = [ + ...without( + this.props.focus.focusHistory, + ...ownFocusedElements + ), + activeElementOnMount, + ]; + + let candidate; + while ( ( candidate = stack.pop() ) ) { + if ( document.body.contains( candidate ) ) { + candidate.focus(); + return; + } + } + } + + render() { + return ( + <div + onFocus={ this.setIsFocusedTrue } + onBlur={ this.setIsFocusedFalse } + > + <WrappedComponent { ...this.props.childProps } /> + </div> + ); + } + } + + return ( props ) => ( + <Consumer> + { ( context ) => <FocusReturn childProps={ props } focus={ context } /> } + </Consumer> + ); + }; +} + +export default createHigherOrderComponent( withFocusReturn, 'withFocusReturn' ); +export { Provider }; diff --git a/gb-src/packages/components/src/higher-order/with-focus-return/test/index.js b/gb-src/packages/components/src/higher-order/with-focus-return/test/index.js new file mode 100644 index 0000000000000..f3e2b326b6dac --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-focus-return/test/index.js @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { Component, createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withFocusReturn, { Provider } from '../'; + +class Test extends Component { + render() { + return ( + <div className="test"><textarea /></div> + ); + } +} + +describe( 'withFocusReturn()', () => { + describe( 'testing rendering and focus handling', () => { + const Composite = withFocusReturn( Test ); + const activeElement = document.createElement( 'button' ); + const switchFocusTo = document.createElement( 'input' ); + + const getInstance = ( wrapper ) => { + return wrapper.root.find( + ( node ) => node.instance instanceof Component + ).instance; + }; + + beforeEach( () => { + activeElement.focus(); + } ); + + afterEach( () => { + activeElement.blur(); + } ); + + it( 'should render a basic Test component inside the HOC', () => { + const renderedComposite = renderer.create( <Composite /> ); + const wrappedElement = renderedComposite.root.findByType( Test ); + const wrappedElementShallow = wrappedElement.children[ 0 ]; + expect( wrappedElementShallow.props.className ).toBe( 'test' ); + expect( wrappedElementShallow.type ).toBe( 'div' ); + expect( wrappedElementShallow.children[ 0 ].type ).toBe( 'textarea' ); + } ); + + it( 'should pass own props through to the wrapped element', () => { + const renderedComposite = renderer.create( <Composite test="test" /> ); + const wrappedElement = renderedComposite.root.findByType( Test ); + // Ensure that the wrapped Test element has the appropriate props. + expect( wrappedElement.props.test ).toBe( 'test' ); + } ); + + it( 'should not pass any withFocusReturn context props through to the wrapped element', () => { + const renderedComposite = renderer.create( <Composite test="test" /> ); + const wrappedElement = renderedComposite.root.findByType( Test ); + // Ensure that the wrapped Test element has the appropriate props. + expect( wrappedElement.props.focusHistory ).toBeUndefined(); + } ); + + it( 'should not switch focus back to the bound focus element', () => { + const mountedComposite = renderer.create( <Composite /> ); + + expect( getInstance( mountedComposite ).activeElementOnMount ).toBe( activeElement ); + + // Change activeElement. + switchFocusTo.focus(); + expect( document.activeElement ).toBe( switchFocusTo ); + + // Should keep focus on switchFocusTo, because it is not within HOC. + mountedComposite.unmount(); + expect( document.activeElement ).toBe( switchFocusTo ); + } ); + + it( 'should switch focus back when unmounted while having focus', () => { + const wrapper = mount( <Composite /> ); + wrapper.find( 'textarea' ).at( 0 ).simulate( 'focus' ); + + // Should return to the activeElement saved with this component. + wrapper.unmount(); + expect( document.activeElement ).toBe( activeElement ); + } ); + + it( 'should switch focus to the most recent still-available focus target', () => { + const container = document.createElement( 'div' ); + document.body.appendChild( container ); + const wrapper = mount( + createElement( + ( props ) => ( + <Provider> + <input name="first" /> + { props.renderSecondInput && <input name="second" /> } + { props.renderComposite && <Composite /> } + </Provider> + ), + { renderSecondInput: true } + ), + { attachTo: container } + ); + + function focus( selector ) { + const childWrapper = wrapper.find( selector ); + const childNode = childWrapper.getDOMNode(); + childWrapper.simulate( 'focus', { target: childNode } ); + } + + focus( 'input[name="first"]' ); + jest.spyOn( wrapper.find( 'input[name="first"]' ).getDOMNode(), 'focus' ); + focus( 'input[name="second"]' ); + wrapper.setProps( { renderComposite: true } ); + focus( 'textarea' ); + wrapper.setProps( { renderSecondInput: false } ); + wrapper.setProps( { renderComposite: false } ); + + expect( wrapper.find( 'input[name="first"]' ).getDOMNode().focus ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/higher-order/with-notices/README.md b/gb-src/packages/components/src/higher-order/with-notices/README.md new file mode 100644 index 0000000000000..9ca098a2bffe4 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-notices/README.md @@ -0,0 +1,19 @@ +# withNotices + +## Usage + +```jsx +import { withNotices, Button } from '@wordpress/components'; + +const MyComponentWithNotices = withNotices( + ( { noticeOperations, noticeUI } ) => { + const addError = () => noticeOperations.createErrorNotice( 'Error message' ); + return ( + <div> + { noticeUI } + <Button isDefault onClick={ addError }>Add error</Button> + </div> + ) + } +); +``` diff --git a/gb-src/packages/components/src/higher-order/with-notices/index.js b/gb-src/packages/components/src/higher-order/with-notices/index.js new file mode 100644 index 0000000000000..49a44608039ce --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-notices/index.js @@ -0,0 +1,103 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import NoticeList from '../../notice/list'; + +/** + * Override the default edit UI to include notices if supported. + * + * @param {Function|Component} OriginalComponent Original component. + * @return {Component} Wrapped component. + */ +export default createHigherOrderComponent( ( OriginalComponent ) => { + return class WrappedBlockEdit extends Component { + constructor() { + super( ...arguments ); + + this.createNotice = this.createNotice.bind( this ); + this.createErrorNotice = this.createErrorNotice.bind( this ); + this.removeNotice = this.removeNotice.bind( this ); + this.removeAllNotices = this.removeAllNotices.bind( this ); + + this.state = { + noticeList: [], + }; + + this.noticeOperations = { + createNotice: this.createNotice, + createErrorNotice: this.createErrorNotice, + removeAllNotices: this.removeAllNotices, + removeNotice: this.removeNotice, + }; + } + + /** + * Function passed down as a prop that adds a new notice. + * + * @param {Object} notice Notice to add. + */ + createNotice( notice ) { + const noticeToAdd = notice.id ? notice : { ...notice, id: uuid() }; + this.setState( ( state ) => ( { + noticeList: [ ...state.noticeList, noticeToAdd ], + } ) ); + } + + /** + * Function passed as a prop that adds a new error notice. + * + * @param {string} msg Error message of the notice. + */ + createErrorNotice( msg ) { + this.createNotice( { status: 'error', content: msg } ); + } + + /** + * Removes a notice by id. + * + * @param {string} id Id of the notice to remove. + */ + removeNotice( id ) { + this.setState( ( state ) => ( { + noticeList: state.noticeList.filter( ( notice ) => notice.id !== id ), + } ) ); + } + + /** + * Removes all notices + */ + removeAllNotices() { + this.setState( { + noticeList: [], + } ); + } + + render() { + return ( + <OriginalComponent + noticeList={ this.state.noticeList } + noticeOperations={ this.noticeOperations } + noticeUI={ + this.state.noticeList.length > 0 && <NoticeList + className="components-with-notices-ui" + notices={ this.state.noticeList } + onRemove={ this.removeNotice } + /> + } + { ...this.props } + /> + ); + } + }; +} ); diff --git a/gb-src/packages/components/src/higher-order/with-spoken-messages/README.md b/gb-src/packages/components/src/higher-order/with-spoken-messages/README.md new file mode 100644 index 0000000000000..5e539b3f3d572 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-spoken-messages/README.md @@ -0,0 +1,14 @@ +# withSpokenMessages + +## Usage + +```jsx +import { withSpokenMessages, Button } from '@wordpress/components'; + +const MyComponentWithSpokenMessages = withSpokenMessages( ( { speak, debouncedSpeak } ) => ( + <div> + <Button isDefault onClick={ () => speak( 'Spoken message' ) }>Speak</Button> + <Button isDefault onClick={ () => debouncedSpeak( 'Delayed message' ) }>Debounced Speak</Button> + </div> +) ); +``` diff --git a/gb-src/packages/components/src/higher-order/with-spoken-messages/index.js b/gb-src/packages/components/src/higher-order/with-spoken-messages/index.js new file mode 100644 index 0000000000000..de17dc62aeb57 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-spoken-messages/index.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * A Higher Order Component used to be provide a unique instance ID by + * component. + * + * @param {WPElement} WrappedComponent The wrapped component. + * + * @return {Component} Component with an instanceId prop. + */ +export default createHigherOrderComponent( ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + this.debouncedSpeak = debounce( this.speak.bind( this ), 500 ); + } + + speak( message, type = 'polite' ) { + speak( message, type ); + } + + componentWillUnmount() { + this.debouncedSpeak.cancel(); + } + + render() { + return ( + <WrappedComponent { ...this.props } + speak={ this.speak } + debouncedSpeak={ this.debouncedSpeak } + /> + ); + } + }; +}, 'withSpokenMessages' ); diff --git a/gb-src/packages/components/src/higher-order/with-spoken-messages/test/index.js b/gb-src/packages/components/src/higher-order/with-spoken-messages/test/index.js new file mode 100644 index 0000000000000..b13164b27c0f7 --- /dev/null +++ b/gb-src/packages/components/src/higher-order/with-spoken-messages/test/index.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { render } from 'enzyme'; +import { isFunction } from 'lodash'; + +/** + * Internal dependencies + */ +import withSpokenMessages from '../'; + +describe( 'withSpokenMessages', () => { + it( 'should generate speak and debouncedSpeak props', () => { + const testSpeak = jest.fn(); + const testDebouncedSpeak = jest.fn(); + const DumpComponent = withSpokenMessages( ( { speak, debouncedSpeak } ) => { + testSpeak( isFunction( speak ) ); + testDebouncedSpeak( isFunction( debouncedSpeak ) ); + return <div />; + } ); + render( <DumpComponent /> ); + + // Unrendered element. + expect( testSpeak ).toHaveBeenCalledWith( true ); + expect( testDebouncedSpeak ).toHaveBeenCalledWith( true ); + } ); +} ); diff --git a/gb-src/packages/components/src/icon-button/README.md b/gb-src/packages/components/src/icon-button/README.md new file mode 100644 index 0000000000000..9637c3b046398 --- /dev/null +++ b/gb-src/packages/components/src/icon-button/README.md @@ -0,0 +1,50 @@ +# IconButton + +Icon buttons tell users what actions they can take and give them a way to interact with the interface. You’ll find them throughout a UI, particularly in places like: + +- Button groups +- Toolbars + +![IconButton component](https://wordpress.org/gutenberg/files/2018/12/s_1D98B71431B26D39301DAD9ADD4189EA9DDF0B98AC93C77E7DE58172FFC06323_1541793686578_IconButton.png) + +![Toolbar component composed of attached IconButtons](https://wordpress.org/gutenberg/files/2018/12/s_1D98B71431B26D39301DAD9ADD4189EA9DDF0B98AC93C77E7DE58172FFC06323_1541793628237_Toolbar.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +# Design guidelines + +## Best practices + +Icon buttons should: + +- **Choose a clear and accurate icon to represent the action.** +- **Clearly communicate that clicking or tapping will trigger an action.** +- **Use established colors appropriately.** For example, only use red buttons for actions that are difficult or impossible to undo. +- **Have consistent locations in the interface.** + +## Anatomy + +Icon buttons have low emphasis. They don’t stand out much on the page, so they’re used for less-important actions. What’s less important can vary based on context, but it’s usually a supplementary action to the main action we want someone to take. Icon buttons are also useful when you don’t want to distract from the content. + +# Development guidelines + +## Usage + +```jsx +import { IconButton } from '@wordpress/components'; + +const MyIconButton = () => ( + <IconButton + icon="ellipsis" + label="More" + /> +); +``` + +# Related components + +- The `Toolbar` component is composed of `IconButton` components. diff --git a/gb-src/packages/components/src/icon-button/index.js b/gb-src/packages/components/src/icon-button/index.js new file mode 100644 index 0000000000000..8c2e361eb2a37 --- /dev/null +++ b/gb-src/packages/components/src/icon-button/index.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isArray, isString } from 'lodash'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tooltip from '../tooltip'; +import Button from '../button'; +import Icon from '../icon'; + +function IconButton( props, ref ) { + const { + icon, + children, + label, + className, + tooltip, + shortcut, + labelPosition, + ...additionalProps + } = props; + const classes = classnames( 'components-icon-button', className, { + 'has-text': children, + } ); + const tooltipText = tooltip || label; + + // Should show the tooltip if... + const showTooltip = ! additionalProps.disabled && ( + // an explicit tooltip is passed or... + tooltip || + // there's a shortcut or... + shortcut || + ( + // there's a label and... + !! label && + // the children are empty and... + ( ! children || ( isArray( children ) && ! children.length ) ) && + // the tooltip is not explicitly disabled. + false !== tooltip + ) + ); + + let element = ( + <Button + aria-label={ label } + { ...additionalProps } + className={ classes } + ref={ ref } + > + { isString( icon ) ? <Icon icon={ icon } /> : icon } + { children } + </Button> + ); + + if ( showTooltip ) { + element = ( + <Tooltip text={ tooltipText } shortcut={ shortcut } position={ labelPosition }> + { element } + </Tooltip> + ); + } + + return element; +} + +export default forwardRef( IconButton ); diff --git a/gb-src/packages/components/src/icon-button/style.scss b/gb-src/packages/components/src/icon-button/style.scss new file mode 100644 index 0000000000000..074ae90b634f4 --- /dev/null +++ b/gb-src/packages/components/src/icon-button/style.scss @@ -0,0 +1,40 @@ +.components-icon-button { + display: flex; + align-items: center; + padding: 8px; + margin: 0; + border: none; + background: none; + color: $dark-gray-500; + position: relative; + overflow: hidden; + border-radius: $radius-round-rectangle; + + .dashicon { + display: inline-block; + flex: 0 0 auto; + } + + // Ensure that even SVG icons that don't include the .dashicon class are colored. + svg { + fill: currentColor; + outline: none; + } + + &.has-text svg { + margin-right: 4px; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + @include button-style__hover; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):active { + @include button-style__active; + } + + &[aria-disabled="true"]:focus, + &:disabled:focus { + box-shadow: none; + } +} diff --git a/gb-src/packages/components/src/icon-button/test/index.js b/gb-src/packages/components/src/icon-button/test/index.js new file mode 100644 index 0000000000000..f4a8d4330d3cc --- /dev/null +++ b/gb-src/packages/components/src/icon-button/test/index.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import TestUtils from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import IconButton from '../'; + +describe( 'IconButton', () => { + describe( 'basic rendering', () => { + it( 'should render an top level element with only a class property', () => { + const iconButton = shallow( <IconButton /> ); + expect( iconButton.hasClass( 'components-icon-button' ) ).toBe( true ); + expect( iconButton.prop( 'aria-label' ) ).toBeUndefined(); + } ); + + it( 'should render a Dashicon component matching the wordpress icon', () => { + const iconButton = shallow( <IconButton icon="wordpress" /> ); + expect( iconButton.find( 'Icon' ).dive().shallow().hasClass( 'dashicons-wordpress' ) ).toBe( true ); + } ); + + it( 'should render child elements when passed as children', () => { + const iconButton = shallow( <IconButton children={ <p className="test">Test</p> } /> ); + expect( iconButton.find( '.test' ).shallow().text() ).toBe( 'Test' ); + } ); + + it( 'should add an aria-label when the label property is used', () => { + const iconButton = shallow( <IconButton label="WordPress">WordPress</IconButton> ); + expect( iconButton.name() ).toBe( 'ForwardRef(Button)' ); + expect( iconButton.prop( 'aria-label' ) ).toBe( 'WordPress' ); + } ); + + it( 'should add an aria-label when the label property is used, with Tooltip wrapper', () => { + const iconButton = shallow( <IconButton label="WordPress" /> ); + expect( iconButton.name() ).toBe( 'Tooltip' ); + expect( iconButton.prop( 'text' ) ).toBe( 'WordPress' ); + expect( iconButton.find( 'ForwardRef(Button)' ).prop( 'aria-label' ) ).toBe( 'WordPress' ); + } ); + + it( 'should support explicit aria-label override', () => { + const iconButton = shallow( <IconButton aria-label="Custom" /> ); + expect( iconButton.prop( 'aria-label' ) ).toBe( 'Custom' ); + } ); + + it( 'should add an additional className', () => { + const iconButton = shallow( <IconButton className="test" /> ); + expect( iconButton.hasClass( 'test' ) ).toBe( true ); + } ); + + it( 'should pass additional props to the underlying button', () => { + const iconButton = shallow( <IconButton disabled aria-pressed="true" /> ); + + expect( iconButton.find( 'ForwardRef(Button)' ).prop( 'aria-pressed' ) ).toBe( 'true' ); + expect( iconButton.find( 'ForwardRef(Button)' ).prop( 'disabled' ) ).toBe( true ); + } ); + + it( 'should allow custom tooltip text', () => { + const iconButton = shallow( <IconButton label="WordPress" tooltip="Custom" /> ); + expect( iconButton.name() ).toBe( 'Tooltip' ); + expect( iconButton.prop( 'text' ) ).toBe( 'Custom' ); + expect( iconButton.find( 'ForwardRef(Button)' ).prop( 'aria-label' ) ).toBe( 'WordPress' ); + } ); + + it( 'should allow tooltip disable', () => { + const iconButton = shallow( <IconButton label="WordPress" tooltip={ false } /> ); + expect( iconButton.name() ).toBe( 'ForwardRef(Button)' ); + expect( iconButton.prop( 'aria-label' ) ).toBe( 'WordPress' ); + } ); + + it( 'should show the tooltip for empty children', () => { + const iconButton = shallow( <IconButton label="WordPress" children={ [] } /> ); + expect( iconButton.name() ).toBe( 'Tooltip' ); + expect( iconButton.prop( 'text' ) ).toBe( 'WordPress' ); + } ); + + it( 'forwards ref', () => { + const ref = createRef(); + + TestUtils.renderIntoDocument( <IconButton ref={ ref } /> ); + expect( ref.current.type ).toBe( 'button' ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/icon/README.md b/gb-src/packages/components/src/icon/README.md new file mode 100644 index 0000000000000..9fbf69609ccbc --- /dev/null +++ b/gb-src/packages/components/src/icon/README.md @@ -0,0 +1,76 @@ +# Icon + +Allows you to render a raw icon without any initial styling or wrappers. + +## Usage + +#### With a Dashicon + +```jsx +import { Icon } from '@wordpress/components'; + +const MyIcon = () => ( + <Icon icon="screenoptions" /> +); +``` + +#### With a function + +```jsx +import { Icon } from '@wordpress/components'; + +const MyIcon = () => ( + <Icon icon={ () => <svg><path d="M5 4v3h5.5v12h3V7H19V4z" /></svg> } /> +); +``` + +#### With a Component + +```jsx +import { MyIconComponent } from '../my-icon-component'; +import { Icon } from '@wordpress/components'; + +const MyIcon = () => ( + <Icon icon={ MyIconComponent } /> +); +``` + +#### With an SVG + +```jsx +import { Icon } from '@wordpress/components'; + +const MyIcon = () => ( + <Icon icon={ <svg><path d="M5 4v3h5.5v12h3V7H19V4z" /></svg> } /> +); +``` + +#### Specifying a className + +```jsx +import { Icon } from '@wordpress/components'; + +const MyIcon = () => ( + <Icon icon="screenoptions" className="example-class" /> +); +``` + +## Props + +The component accepts the following props. Any additional props are passed through to the underlying icon element. + +### icon + +The icon to render. Supported values are: Dashicons (specified as strings), functions, WPComponent instances and `null`. + +- Type: `String|Function|WPComponent|null` +- Required: No +- Default: `null` + +### size + +The size (width and height) of the icon. + +- Type: `Number` +- Required: No +- Default: `20` when a Dashicon is rendered, `24` for all other icons. diff --git a/gb-src/packages/components/src/icon/index.js b/gb-src/packages/components/src/icon/index.js new file mode 100644 index 0000000000000..4a67f28bc2967 --- /dev/null +++ b/gb-src/packages/components/src/icon/index.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { cloneElement, createElement, Component, isValidElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Dashicon from '../dashicon'; +import { SVG } from '../primitives'; + +function Icon( { icon = null, size, ...additionalProps } ) { + let iconSize; + + if ( 'string' === typeof icon ) { + // Dashicons should be 20x20 by default + iconSize = size || 20; + return <Dashicon icon={ icon } size={ iconSize } { ...additionalProps } />; + } + + // Any other icons should be 24x24 by default + iconSize = size || 24; + + if ( 'function' === typeof icon ) { + if ( icon.prototype instanceof Component ) { + return createElement( icon, { size: iconSize, ...additionalProps } ); + } + + return icon( { size: iconSize, ...additionalProps } ); + } + + if ( icon && ( icon.type === 'svg' || icon.type === SVG ) ) { + const appliedProps = { + width: iconSize, + height: iconSize, + ...icon.props, + ...additionalProps, + }; + + return <SVG { ...appliedProps } />; + } + + if ( isValidElement( icon ) ) { + return cloneElement( icon, { + size: iconSize, + ...additionalProps, + } ); + } + + return icon; +} + +export default Icon; diff --git a/gb-src/packages/components/src/icon/test/index.js b/gb-src/packages/components/src/icon/test/index.js new file mode 100644 index 0000000000000..053b0cf390ff5 --- /dev/null +++ b/gb-src/packages/components/src/icon/test/index.js @@ -0,0 +1,126 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Icon from '../'; +import { Path, SVG } from '../../'; + +describe( 'Icon', () => { + const className = 'example-class'; + const svg = <SVG><Path d="M5 4v3h5.5v12h3V7H19V4z" /></SVG>; + const style = { fill: 'red' }; + + it( 'renders nothing when icon omitted', () => { + const wrapper = shallow( <Icon /> ); + + expect( wrapper.type() ).toBeNull(); + } ); + + it( 'renders a dashicon by slug', () => { + const wrapper = shallow( <Icon icon="format-image" /> ); + + expect( wrapper.find( 'Dashicon' ).prop( 'icon' ) ).toBe( 'format-image' ); + } ); + + it( 'renders a dashicon and with a default size of 20', () => { + const wrapper = shallow( <Icon icon="format-image" /> ); + + expect( wrapper.find( 'Dashicon' ).prop( 'size' ) ).toBe( 20 ); + } ); + + it( 'renders a function', () => { + const wrapper = shallow( <Icon icon={ () => <span /> } /> ); + + expect( wrapper.name() ).toBe( 'span' ); + } ); + + it( 'renders an element', () => { + const wrapper = shallow( <Icon icon={ <span /> } /> ); + + expect( wrapper.name() ).toBe( 'span' ); + } ); + + it( 'renders an svg element', () => { + const wrapper = shallow( <Icon icon={ svg } /> ); + + expect( wrapper.name() ).toBe( 'SVG' ); + } ); + + it( 'renders an svg element with a default width and height of 24', () => { + const wrapper = shallow( <Icon icon={ svg } /> ); + + expect( wrapper.prop( 'width' ) ).toBe( 24 ); + expect( wrapper.prop( 'height' ) ).toBe( 24 ); + } ); + + it( 'renders an svg element and passes the size as its width and height', () => { + const wrapper = shallow( <Icon icon={ <SVG width={ 64 } height={ 64 }><Path d="M5 4v3h5.5v12h3V7H19V4z" /></SVG> } size={ 32 } /> ); + + expect( wrapper.prop( 'width' ) ).toBe( 64 ); + expect( wrapper.prop( 'height' ) ).toBe( 64 ); + } ); + + it( 'renders an svg element and does not override width and height if already specified', () => { + const wrapper = shallow( <Icon icon={ svg } size={ 32 } /> ); + + expect( wrapper.prop( 'width' ) ).toBe( 32 ); + expect( wrapper.prop( 'height' ) ).toBe( 32 ); + } ); + + it( 'renders a component', () => { + class MyComponent extends Component { + render() { + return <span />; + } + } + const wrapper = shallow( + <Icon icon={ MyComponent } /> + ); + + expect( wrapper.name() ).toBe( 'MyComponent' ); + } ); + + describe( 'props passing', () => { + class MyComponent extends Component { + render( ) { + return <span className={ this.props.className } />; + } + } + + describe.each( [ + [ 'dashicon', { icon: 'format-image' } ], + [ 'element', { icon: <span /> } ], + [ 'svg element', { icon: svg } ], + [ 'component', { icon: MyComponent } ], + ] )( '%s', ( label, props ) => { + it( 'should pass through size', () => { + if ( label === 'svg element' ) { + // Custom logic for SVG elements tested separately. + // + // See: `renders an svg element and passes the size as its width and height` + return; + } + + const wrapper = shallow( <Icon { ...props } size={ 32 } /> ); + + expect( wrapper.prop( 'size' ) ).toBe( 32 ); + } ); + + it( 'should pass through all other props', () => { + const wrapper = shallow( <Icon { ...props } style={ style } className={ className } /> ); + + expect( wrapper.prop( 'style' ) ).toBe( style ); + expect( wrapper.prop( 'className' ) ).toBe( className ); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/index.js b/gb-src/packages/components/src/index.js new file mode 100644 index 0000000000000..5ce67b968084e --- /dev/null +++ b/gb-src/packages/components/src/index.js @@ -0,0 +1,75 @@ +// Components +export * from './primitives'; +export { default as Animate } from './animate'; +export { default as Autocomplete } from './autocomplete'; +export { default as BaseControl } from './base-control'; +export { default as Button } from './button'; +export { default as ButtonGroup } from './button-group'; +export { default as CheckboxControl } from './checkbox-control'; +export { default as ClipboardButton } from './clipboard-button'; +export { default as ColorIndicator } from './color-indicator'; +export { default as ColorPalette } from './color-palette'; +export { default as ColorPicker } from './color-picker'; +export { default as Dashicon } from './dashicon'; +export { DateTimePicker, DatePicker, TimePicker } from './date-time'; +export { default as Disabled } from './disabled'; +export { default as Draggable } from './draggable'; +export { default as DropZone } from './drop-zone'; +export { default as DropZoneProvider } from './drop-zone/provider'; +export { default as Dropdown } from './dropdown'; +export { default as DropdownMenu } from './dropdown-menu'; +export { default as ExternalLink } from './external-link'; +export { default as FocalPointPicker } from './focal-point-picker'; +export { default as FocusableIframe } from './focusable-iframe'; +export { default as FontSizePicker } from './font-size-picker'; +export { default as FormFileUpload } from './form-file-upload'; +export { default as FormToggle } from './form-toggle'; +export { default as FormTokenField } from './form-token-field'; +export { default as Icon } from './icon'; +export { default as IconButton } from './icon-button'; +export { default as KeyboardShortcuts } from './keyboard-shortcuts'; +export { default as MenuGroup } from './menu-group'; +export { default as MenuItem } from './menu-item'; +export { default as MenuItemsChoice } from './menu-items-choice'; +export { default as Modal } from './modal'; +export { default as ScrollLock } from './scroll-lock'; +export { NavigableMenu, TabbableContainer } from './navigable-container'; +export { default as Notice } from './notice'; +export { default as NoticeList } from './notice/list'; +export { default as Panel } from './panel'; +export { default as PanelBody } from './panel/body'; +export { default as PanelHeader } from './panel/header'; +export { default as PanelRow } from './panel/row'; +export { default as Placeholder } from './placeholder'; +export { default as Popover } from './popover'; +export { default as QueryControls } from './query-controls'; +export { default as RadioControl } from './radio-control'; +export { default as RangeControl } from './range-control'; +export { default as ResizableBox } from './resizable-box'; +export { default as ResponsiveWrapper } from './responsive-wrapper'; +export { default as SandBox } from './sandbox'; +export { default as SelectControl } from './select-control'; +export { default as Snackbar } from './snackbar'; +export { default as SnackbarList } from './snackbar/list'; +export { default as Spinner } from './spinner'; +export { default as TabPanel } from './tab-panel'; +export { default as TextControl } from './text-control'; +export { default as TextareaControl } from './textarea-control'; +export { default as Tip } from './tip'; +export { default as ToggleControl } from './toggle-control'; +export { default as Toolbar } from './toolbar'; +export { default as ToolbarButton } from './toolbar-button'; +export { default as Tooltip } from './tooltip'; +export { default as TreeSelect } from './tree-select'; +export { default as IsolatedEventContainer } from './isolated-event-container'; +export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; + +// Higher-Order Components +export { default as navigateRegions } from './higher-order/navigate-regions'; +export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; +export { default as withFallbackStyles } from './higher-order/with-fallback-styles'; +export { default as withFilters } from './higher-order/with-filters'; +export { default as withFocusOutside } from './higher-order/with-focus-outside'; +export { default as withFocusReturn, Provider as FocusReturnProvider } from './higher-order/with-focus-return'; +export { default as withNotices } from './higher-order/with-notices'; +export { default as withSpokenMessages } from './higher-order/with-spoken-messages'; diff --git a/gb-src/packages/components/src/index.native.js b/gb-src/packages/components/src/index.native.js new file mode 100644 index 0000000000000..4984cdde2fd36 --- /dev/null +++ b/gb-src/packages/components/src/index.native.js @@ -0,0 +1,29 @@ +// Components +export * from './primitives'; +export { default as Dashicon } from './dashicon'; +export { default as Dropdown } from './dropdown'; +export { default as Toolbar } from './toolbar'; +export { default as ToolbarButton } from './toolbar-button'; +export { default as Icon } from './icon'; +export { default as IconButton } from './icon-button'; +export { default as Spinner } from './spinner'; +export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; +export { default as BaseControl } from './base-control'; +export { default as TextareaControl } from './textarea-control'; + +// Higher-Order Components +export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; +export { default as withFallbackStyles } from './higher-order/with-fallback-styles'; +export { default as withFilters } from './higher-order/with-filters'; +export { default as withFocusOutside } from './higher-order/with-focus-outside'; +export { default as withFocusReturn } from './higher-order/with-focus-return'; +export { default as withNotices } from './higher-order/with-notices'; +export { default as withSpokenMessages } from './higher-order/with-spoken-messages'; + +// Mobile Components +export { default as BottomSheet } from './mobile/bottom-sheet'; +export { default as HTMLTextInput } from './mobile/html-text-input'; +export { default as KeyboardAvoidingView } from './mobile/keyboard-avoiding-view'; +export { default as KeyboardAwareFlatList } from './mobile/keyboard-aware-flat-list'; +export { default as Picker } from './mobile/picker'; +export { default as ReadableContentView } from './mobile/readable-content-view'; diff --git a/gb-src/packages/components/src/isolated-event-container/README.md b/gb-src/packages/components/src/isolated-event-container/README.md new file mode 100644 index 0000000000000..90ce7624f4167 --- /dev/null +++ b/gb-src/packages/components/src/isolated-event-container/README.md @@ -0,0 +1,34 @@ +# Isolated Event Container + +This is a container that prevents certain events from propagating outside of the container. This is used to wrap +UI elements such as modals and popovers where the propagated event can cause problems. The event continues to work +inside the component. + +For example, a `mousedown` event in a modal container can propagate to the surrounding DOM, causing UI outside of the +modal to be interacted with. + +The current isolated events are: +- mousedown - This prevents UI interaction with other `mousedown` event handlers, such as selection + +## Usage + +Creates a custom component that won't propagate `mousedown` events outside of the component. + +```jsx +import { IsolatedEventContainer } from '@wordpress/components'; + +const MyModal = () => { + return ( + <IsolatedEventContainer + className="component-some_component" + onClick={ clickHandler } + > + <p>This is an isolated component</p> + </IsolatedEventContainer> + ); +}; +``` + +## Props + +All props are passed as-is to the `<IsolatedEventContainer />` diff --git a/gb-src/packages/components/src/isolated-event-container/index.js b/gb-src/packages/components/src/isolated-event-container/index.js new file mode 100644 index 0000000000000..1f305071c95cf --- /dev/null +++ b/gb-src/packages/components/src/isolated-event-container/index.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +class IsolatedEventContainer extends Component { + constructor( props ) { + super( props ); + + this.stopEventPropagationOutsideContainer = this.stopEventPropagationOutsideContainer.bind( this ); + } + + stopEventPropagationOutsideContainer( event ) { + event.stopPropagation(); + } + + render() { + const { children, ... props } = this.props; + + // Disable reason: this stops certain events from propagating outside of the component. + // - onMouseDown is disabled as this can cause interactions with other DOM elements + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + { ... props } + onMouseDown={ this.stopEventPropagationOutsideContainer } + > + { children } + </div> + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +export default IsolatedEventContainer; diff --git a/gb-src/packages/components/src/isolated-event-container/test/index.js b/gb-src/packages/components/src/isolated-event-container/test/index.js new file mode 100644 index 0000000000000..7d34c53961365 --- /dev/null +++ b/gb-src/packages/components/src/isolated-event-container/test/index.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import IsolatedEventContainer from '../'; + +describe( 'IsolatedEventContainer', () => { + it( 'should pass props to container', () => { + const isolated = shallow( <IsolatedEventContainer className="test" onClick="click" /> ); + + expect( isolated.hasClass( 'test' ) ).toBe( true ); + expect( isolated.prop( 'onClick' ) ).toBe( 'click' ); + } ); + + it( 'should stop mousedown event propagation', () => { + const isolated = shallow( <IsolatedEventContainer /> ); + const event = { stopPropagation: jest.fn() }; + + isolated.simulate( 'mousedown', event ); + expect( event.stopPropagation ).toHaveBeenCalled(); + } ); +} ); diff --git a/gb-src/packages/components/src/keyboard-shortcuts/README.md b/gb-src/packages/components/src/keyboard-shortcuts/README.md new file mode 100644 index 0000000000000..7c1046087d9b3 --- /dev/null +++ b/gb-src/packages/components/src/keyboard-shortcuts/README.md @@ -0,0 +1,75 @@ +# Keyboard Shortcuts + +`<KeyboardShortcuts />` is a component which handles keyboard sequences during the lifetime of the rendering element. + +When passed children, it will capture key events which occur on or within the children. If no children are passed, events are captured on the document. + +It uses the [Mousetrap](https://craig.is/killing/mice) library to implement keyboard sequence bindings. + +## Example + +Render `<KeyboardShortcuts />` with a `shortcuts` prop object: + +```jsx +import { KeyboardShortcuts } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyKeyboardShortcuts = withState( { + isAllSelected: false, +} )( ( { isAllSelected, setState } ) => { + const selectAll = () => { + setState( { isAllSelected: true } ) + }; + + return ( + <div> + <KeyboardShortcuts shortcuts={ { + 'mod+a': selectAll, + } } /> + [cmd/ctrl + A] Combination pressed? { isAllSelected ? 'Yes' : 'No' } + </div> + ) +} ) +``` + +## Props + +The component accepts the following props: + +### children + +Elements to render, upon whom key events are to be monitored. + +- Type: `Element` | `Element[]` +- Required: No + +### shortcuts + +An object of shortcut bindings, where each key is a keyboard combination, the value of which is the callback to be invoked when the key combination is pressed. + +- Type: `Object` +- Required: Yes + +__Note:__ The value of each shortcut should be a consistent function reference, not an anonymous function. Otherwise, the callback will not be correctly unbound when the component unmounts. + +__Note:__ The `KeyboardShortcuts` component will not update to reflect a changed `shortcuts` prop. If you need to change shortcuts, mount a separate `KeyboardShortcuts` element, which can be achieved by assigning a unique `key` prop. + +### bindGlobal + +By default, a callback will not be invoked if the key combination occurs in an editable field. Pass `bindGlobal` as `true` if the key events should be observed globally, including within editable fields. + +- Type: `Boolean` +- Required: No + +_Tip:_ If you need some but not all keyboard events to be observed globally, simply render two distinct `KeyboardShortcuts` elements, one with and one without the `bindGlobal` prop. + +### eventName + +By default, a callback is invoked in response to the `keydown` event. To override this, pass `eventName` with the name of a specific keyboard event. + +- Type: `String` +- Required: No + +## References + +- [Mousetrap documentation](https://craig.is/killing/mice) diff --git a/gb-src/packages/components/src/keyboard-shortcuts/index.js b/gb-src/packages/components/src/keyboard-shortcuts/index.js new file mode 100644 index 0000000000000..5bb2cee5499c6 --- /dev/null +++ b/gb-src/packages/components/src/keyboard-shortcuts/index.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import Mousetrap from 'mousetrap'; +import 'mousetrap/plugins/global-bind/mousetrap-global-bind'; +import { forEach } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, Children } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { isAppleOS } from './platform'; + +class KeyboardShortcuts extends Component { + constructor() { + super( ...arguments ); + + this.bindKeyTarget = this.bindKeyTarget.bind( this ); + } + + componentDidMount() { + const { keyTarget = document } = this; + + this.mousetrap = new Mousetrap( keyTarget ); + + forEach( this.props.shortcuts, ( callback, key ) => { + if ( process.env.NODE_ENV === 'development' ) { + const keys = key.split( '+' ); + const modifiers = new Set( keys.filter( ( value ) => value.length > 1 ) ); + const hasAlt = modifiers.has( 'alt' ); + const hasShift = modifiers.has( 'shift' ); + + if ( + isAppleOS() && ( + ( modifiers.size === 1 && hasAlt ) || + ( modifiers.size === 2 && hasAlt && hasShift ) + ) + ) { + throw new Error( `Cannot bind ${ key }. Alt and Shift+Alt modifiers are reserved for character input.` ); + } + } + + const { bindGlobal, eventName } = this.props; + const bindFn = bindGlobal ? 'bindGlobal' : 'bind'; + this.mousetrap[ bindFn ]( key, callback, eventName ); + } ); + } + + componentWillUnmount() { + this.mousetrap.reset(); + } + + /** + * When rendering with children, binds the wrapper node on which events + * will be bound. + * + * @param {Element} node Key event target. + */ + bindKeyTarget( node ) { + this.keyTarget = node; + } + + render() { + // Render as non-visual if there are no children pressed. Keyboard + // events will be bound to the document instead. + const { children } = this.props; + if ( ! Children.count( children ) ) { + return null; + } + + return <div ref={ this.bindKeyTarget }>{ children }</div>; + } +} + +export default KeyboardShortcuts; diff --git a/gb-src/packages/components/src/keyboard-shortcuts/platform.js b/gb-src/packages/components/src/keyboard-shortcuts/platform.js new file mode 100644 index 0000000000000..386f0d7fb173f --- /dev/null +++ b/gb-src/packages/components/src/keyboard-shortcuts/platform.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * Return true if platform is MacOS. + * + * @param {Object} _window window object by default; used for DI testing. + * + * @return {boolean} True if MacOS; false otherwise. + */ +export function isAppleOS( _window = window ) { + const { platform } = _window.navigator; + + return platform.indexOf( 'Mac' ) !== -1 || + includes( [ 'iPad', 'iPhone' ], platform ); +} diff --git a/gb-src/packages/components/src/keyboard-shortcuts/test/index.js b/gb-src/packages/components/src/keyboard-shortcuts/test/index.js new file mode 100644 index 0000000000000..caaa459780b0a --- /dev/null +++ b/gb-src/packages/components/src/keyboard-shortcuts/test/index.js @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import KeyboardShortcuts from '../'; + +describe( 'KeyboardShortcuts', () => { + afterEach( () => { + while ( document.body.firstChild ) { + document.body.removeChild( document.body.firstChild ); + } + } ); + + function keyPress( which, target ) { + [ 'keydown', 'keypress', 'keyup' ].forEach( ( eventName ) => { + const event = new window.Event( eventName, { bubbles: true } ); + event.keyCode = which; + event.which = which; + target.dispatchEvent( event ); + } ); + } + + it( 'should capture key events', () => { + const spy = jest.fn(); + mount( + <KeyboardShortcuts + shortcuts={ { + d: spy, + } } /> + ); + + keyPress( 68, document ); + + expect( spy ).toHaveBeenCalled(); + } ); + + it( 'should capture key events globally', () => { + const spy = jest.fn(); + const attachNode = document.createElement( 'div' ); + document.body.appendChild( attachNode ); + + const wrapper = mount( + <div> + <KeyboardShortcuts + bindGlobal + shortcuts={ { + d: spy, + } } /> + <textarea></textarea> + </div>, + { attachTo: attachNode } + ); + + keyPress( 68, wrapper.find( 'textarea' ).getDOMNode() ); + + expect( spy ).toHaveBeenCalled(); + } ); + + it( 'should capture key events on specific event', () => { + const spy = jest.fn(); + const attachNode = document.createElement( 'div' ); + document.body.appendChild( attachNode ); + + const wrapper = mount( + <div> + <KeyboardShortcuts + eventName="keyup" + shortcuts={ { + d: spy, + } } /> + <textarea></textarea> + </div>, + { attachTo: attachNode } + ); + + keyPress( 68, wrapper.find( 'textarea' ).getDOMNode() ); + + expect( spy ).toHaveBeenCalled(); + expect( spy.mock.calls[ 0 ][ 0 ].type ).toBe( 'keyup' ); + } ); + + it( 'should capture key events on children', () => { + const spy = jest.fn(); + const attachNode = document.createElement( 'div' ); + document.body.appendChild( attachNode ); + + const wrapper = mount( + <div> + <KeyboardShortcuts + shortcuts={ { + d: spy, + } } + > + <textarea></textarea> + </KeyboardShortcuts> + <textarea></textarea> + </div>, + { attachTo: attachNode } + ); + + const textareas = wrapper.find( 'textarea' ); + + // Outside scope + keyPress( 68, textareas.at( 1 ).getDOMNode() ); + expect( spy ).not.toHaveBeenCalled(); + + // Inside scope + keyPress( 68, textareas.at( 0 ).getDOMNode() ); + expect( spy ).toHaveBeenCalled(); + } ); +} ); diff --git a/gb-src/packages/components/src/menu-group/README.md b/gb-src/packages/components/src/menu-group/README.md new file mode 100644 index 0000000000000..837c6f28e0e97 --- /dev/null +++ b/gb-src/packages/components/src/menu-group/README.md @@ -0,0 +1,45 @@ +# MenuGroup + +`MenuGroup` wraps a series of related `MenuItem` components into a common section. + +![MenuGroup Example](https://wordpress.org/gutenberg/files/2019/03/MenuGroup.png) + +1. MenuGroup + +## Table of Contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +A `MenuGroup` should be used to indicate that two or more individual MenuItems are related. When other menu items exist above or below a `MenuGroup`, the group should have a divider line between it and the adjacent item. A `MenuGroup` can optionally include a label to describe its contents. + +![MenuGroup diagram with label and dividers](https://wordpress.org/gutenberg/files/2019/03/MenuGroup-Anatomy.png) + +1. `MenuGroup` label +2. `MenuGroup` dividers + +## Development guidelines + +### Usage + +```jsx +import { MenuGroup, MenuItem } from '@wordpress/components'; + +const MyMenuGroup = () => ( + <MenuGroup label="Settings"> + <MenuItem>Setting 1</MenuItem> + <MenuItem>Setting 2</MenuItem> + </MenuGroup> +); +``` + +## Related Components + +- `MenuGroup`s are intended to be used in a `DropDownMenu`. +- To use a single button in a menu, use `MenuItem`. +- To allow users to toggle between a set of menu options, use `MenuItemsChoice` inside of a `MenuGroup`. diff --git a/gb-src/packages/components/src/menu-group/index.js b/gb-src/packages/components/src/menu-group/index.js new file mode 100644 index 0000000000000..ba98c5fea4fd2 --- /dev/null +++ b/gb-src/packages/components/src/menu-group/index.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Children } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; + +export function MenuGroup( { + children, + className = '', + instanceId, + label, +} ) { + if ( ! Children.count( children ) ) { + return null; + } + + const labelId = `components-menu-group-label-${ instanceId }`; + const classNames = classnames( + className, + 'components-menu-group' + ); + + return ( + <div className={ classNames }> + { label && + <div + className="components-menu-group__label" + id={ labelId } + aria-hidden="true" + > + { label } + </div> + } + <div role="group" aria-labelledby={ label ? labelId : null }> + { children } + </div> + </div> + ); +} + +export default withInstanceId( MenuGroup ); diff --git a/gb-src/packages/components/src/menu-group/style.scss b/gb-src/packages/components/src/menu-group/style.scss new file mode 100644 index 0000000000000..6682581a5bfab --- /dev/null +++ b/gb-src/packages/components/src/menu-group/style.scss @@ -0,0 +1,10 @@ +.components-menu-group { + width: 100%; + padding: ($grid-size - $border-width) 0; +} + +.components-menu-group__label { + margin-bottom: $grid-size; + color: $dark-gray-300; + padding: 0 ($grid-size - $border-width); +} diff --git a/gb-src/packages/components/src/menu-group/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/menu-group/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..4a0c0e751a266 --- /dev/null +++ b/gb-src/packages/components/src/menu-group/test/__snapshots__/index.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuGroup should match snapshot 1`] = ` +<div + className="components-menu-group" +> + <div + aria-hidden="true" + className="components-menu-group__label" + id="components-menu-group-label-1" + > + My group + </div> + <div + aria-labelledby="components-menu-group-label-1" + role="group" + > + <p> + My item + </p> + </div> +</div> +`; diff --git a/gb-src/packages/components/src/menu-group/test/index.js b/gb-src/packages/components/src/menu-group/test/index.js new file mode 100644 index 0000000000000..7f5a476614b22 --- /dev/null +++ b/gb-src/packages/components/src/menu-group/test/index.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { MenuGroup } from '../'; + +describe( 'MenuGroup', () => { + test( 'should render null when no children provided', () => { + const wrapper = shallow( <MenuGroup /> ); + + expect( wrapper.html() ).toBe( null ); + } ); + + test( 'should match snapshot', () => { + const wrapper = shallow( + <MenuGroup + label="My group" + instanceId="1" + > + <p>My item</p> + </MenuGroup> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/components/src/menu-item/README.md b/gb-src/packages/components/src/menu-item/README.md new file mode 100644 index 0000000000000..1741f8102992f --- /dev/null +++ b/gb-src/packages/components/src/menu-item/README.md @@ -0,0 +1,71 @@ +# MenuItem + +MenuItem is a component which renders a button intended to be used in combination with the [DropdownMenu component](/packages/components/src/dropdown-menu/README.md). + +## Usage + +```jsx +import { MenuItem } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyMenuItem = withState( { + isActive: true, +} )( ( { isActive, setState } ) => ( + <MenuItem + icon={ isActive ? 'yes' : 'no' } + isSelected={ isActive } + onClick={ () => setState( state => ( { isActive: ! state.isActive } ) ) } + > + Toggle + </MenuItem> +) ); +``` + +## Props + +MenuItem supports the following props. Any additional props are passed through to the underlying [Button](/packages/components/src/button/README.md) or [IconButton](/packages/components/src/icon-button/README.md) component. + +### `children` + +- Type: `WPElement` +- Required: No + +Element to render as child of button. + +### `info` + +- Type: `string` +- Required: No + +Text to use as description for button text. + +Refer to documentation for [`label`](#label). + +### `icon` + +- Type: `string` +- Required: No + +Refer to documentation for [IconButton's `icon` prop](/packages/components/src/icon-button/README.md#icon). + +### `isSelected` + +- Type: `boolean` +- Required: No + +Whether or not the menu item is currently selected. + +### `shortcut` + +- Type: `string` +- Required: No + +Refer to documentation for [Shortcut's `shortcut` prop](/packages/components/src/shortcut/README.md#shortcut). + +### `role` + +- Type: `string` +- Require: No +- Default: `'menuitem'` + +[Aria Spec](https://www.w3.org/TR/wai-aria-1.1/#aria-checked). If you need to have selectable menu items use menuitemradio for single select, and menuitemcheckbox for multiselect. diff --git a/gb-src/packages/components/src/menu-item/index.js b/gb-src/packages/components/src/menu-item/index.js new file mode 100644 index 0000000000000..b7cc7c8ca7a0b --- /dev/null +++ b/gb-src/packages/components/src/menu-item/index.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isString } from 'lodash'; + +/** + * WordPress dependencies + */ +import { cloneElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Shortcut from '../shortcut'; +import IconButton from '../icon-button'; + +/** + * Renders a generic menu item for use inside the more menu. + * + * @return {WPElement} More menu item. + */ +export function MenuItem( { + children, + info, + className, + icon, + shortcut, + isSelected, + role = 'menuitem', + ...props +} ) { + className = classnames( 'components-menu-item__button', className, { + 'has-icon': icon, + } ); + + if ( info ) { + children = ( + <span className="components-menu-item__info-wrapper"> + { children } + <span + className="components-menu-item__info"> + { info } + </span> + </span> + ); + } + + if ( icon && ! isString( icon ) ) { + icon = cloneElement( icon, { + className: 'components-menu-items__item-icon', + height: 20, + width: 20, + } ); + } + + return ( + <IconButton + icon={ icon } + // Make sure aria-checked matches spec https://www.w3.org/TR/wai-aria-1.1/#aria-checked + aria-checked={ ( role === 'menuitemcheckbox' || role === 'menuitemradio' ) ? isSelected : undefined } + role={ role } + className={ className } + { ...props } + > + { children } + <Shortcut className="components-menu-item__shortcut" shortcut={ shortcut } /> + </IconButton> + ); +} + +export default MenuItem; diff --git a/gb-src/packages/components/src/menu-item/style.scss b/gb-src/packages/components/src/menu-item/style.scss new file mode 100644 index 0000000000000..b854dab40915c --- /dev/null +++ b/gb-src/packages/components/src/menu-item/style.scss @@ -0,0 +1,60 @@ +.components-menu-item__button, +.components-menu-item__button.components-icon-button { + width: 100%; + padding: $grid-size ($grid-size-large - $border-width); + text-align: left; + color: $dark-gray-600; + @include menu-style__neutral; + + // Target plugin icons that can have arbitrary classes by using an aggressive selector. + .dashicon, + .components-menu-items__item-icon, + > span > svg { + margin-right: 5px; + } + + .components-menu-items__item-icon { + display: inline-block; + flex: 0 0 auto; + } + + &:hover:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__hover; + + .components-menu-item__shortcut { + color: $dark-gray-600; + } + } + + &:focus:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__focus; + } +} + +.components-menu-item__info-wrapper { + display: flex; + flex-direction: column; +} + +.components-menu-item__info { + margin-top: $grid-size-small; + font-size: $default-font-size - 1px; + color: $dark-gray-300; +} + +.components-menu-item__shortcut { + align-self: center; + color: $dark-gray-300; + margin-right: 0; + margin-left: auto; + padding-left: $grid-size; + + // Hide the keyboard shortcuts on mobile, because they aren't super-useful + // for most mobile users and it frees up screen space for any item + // with a long description. + display: none; + @include break-mobile() { + display: inline; + } +} + diff --git a/gb-src/packages/components/src/menu-item/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/menu-item/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..df61af88f86c8 --- /dev/null +++ b/gb-src/packages/components/src/menu-item/test/__snapshots__/index.js.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuItem should match snapshot when all props provided 1`] = ` +<ForwardRef(IconButton) + aria-checked={true} + className="components-menu-item__button my-class has-icon" + icon="wordpress" + onClick={[Function]} + role="menuitemcheckbox" +> + My item + <Shortcut + className="components-menu-item__shortcut" + shortcut="mod+shift+alt+w" + /> +</ForwardRef(IconButton)> +`; + +exports[`MenuItem should match snapshot when info is provided 1`] = ` +<ForwardRef(IconButton) + className="components-menu-item__button" + role="menuitem" +> + <span + className="components-menu-item__info-wrapper" + > + My item + <span + className="components-menu-item__info" + > + Extended description of My Item + </span> + </span> + <Shortcut + className="components-menu-item__shortcut" + /> +</ForwardRef(IconButton)> +`; + +exports[`MenuItem should match snapshot when isSelected and role are optionally provided 1`] = ` +<ForwardRef(IconButton) + className="components-menu-item__button my-class has-icon" + icon="wordpress" + onClick={[Function]} + role="menuitem" +> + My item + <Shortcut + className="components-menu-item__shortcut" + shortcut="mod+shift+alt+w" + /> +</ForwardRef(IconButton)> +`; + +exports[`MenuItem should match snapshot when only label provided 1`] = ` +<ForwardRef(IconButton) + className="components-menu-item__button" + role="menuitem" +> + My item + <Shortcut + className="components-menu-item__shortcut" + /> +</ForwardRef(IconButton)> +`; diff --git a/gb-src/packages/components/src/menu-item/test/index.js b/gb-src/packages/components/src/menu-item/test/index.js new file mode 100644 index 0000000000000..63ac41b337dff --- /dev/null +++ b/gb-src/packages/components/src/menu-item/test/index.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { MenuItem } from '../'; + +describe( 'MenuItem', () => { + it( 'should match snapshot when only label provided', () => { + const wrapper = shallow( + <MenuItem> + My item + </MenuItem> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should match snapshot when all props provided', () => { + const wrapper = shallow( + <MenuItem + className="my-class" + icon="wordpress" + isSelected={ true } + role="menuitemcheckbox" + onClick={ noop } + shortcut="mod+shift+alt+w" + > + My item + </MenuItem> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should match snapshot when isSelected and role are optionally provided', () => { + const wrapper = shallow( + <MenuItem + className="my-class" + icon="wordpress" + onClick={ noop } + shortcut="mod+shift+alt+w" + > + My item + </MenuItem> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should match snapshot when info is provided', () => { + const wrapper = shallow( + <MenuItem info="Extended description of My Item"> + My item + </MenuItem> + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should avoid using aria-label if only has non-string children', () => { + const wrapper = shallow( + <MenuItem><div /></MenuItem> + ); + + expect( wrapper.prop( 'aria-label' ) ).toBeUndefined(); + } ); + + it( 'should avoid using aria-checked if only menuitem is set as aria-role', () => { + const wrapper = shallow( + <MenuItem role="menuitem" isSelected={ true }><div /></MenuItem> + ); + + expect( wrapper.prop( 'aria-checked' ) ).toBeUndefined(); + } ); + + it( 'should use aria-checked if menuitemradio or menuitemcheckbox is set as aria-role', () => { + let wrapper = shallow( + <MenuItem role="menuitemradio" isSelected={ true }><div /></MenuItem> + ); + + expect( wrapper.prop( 'aria-checked' ) ).toBe( true ); + + wrapper = shallow( + <MenuItem role="menuitemcheckbox" isSelected={ true }><div /></MenuItem> + ); + + expect( wrapper.prop( 'aria-checked' ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/components/src/menu-items-choice/README.md b/gb-src/packages/components/src/menu-items-choice/README.md new file mode 100644 index 0000000000000..bd294f3566389 --- /dev/null +++ b/gb-src/packages/components/src/menu-items-choice/README.md @@ -0,0 +1,82 @@ +# MenuItemsChoice + +`MenuItemsChoice` functions similarly to a set of `MenuItem`s, but allows the user to select one option from a set of multiple choices. + +![MenuItemsChoice Example](https://wordpress.org/gutenberg/files/2019/03/MenuItemsChoice.png) + +1. MenuItemsChoice + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) + +## Design guidelines + +A `MenuItemsChoice` should be housed within in its own distinct `MenuGroup`, so that the set of options are distinct from nearby `MenuItems`. + +### Usage + +`MenuItemsChoice` is used in a `DropdownMenu` to present users with a set of options. When one option in a `MenuItemsChoice` is selected, the others are automatically deselected. + +![MenuItemsChoice Diagram](https://wordpress.org/gutenberg/files/2019/03/MenuItemsChoice-Checkbox-Keyboard-Shortcut.png) + +1. A checkmark icon appears next to the choice when it’s enabled, and disappears when disabled. +2. If an item in `MenuItemsChoice` has an associated keyboard shortcut, that should be displayed to the right of the menu title, aligned to the right side of the menu item. Selected choices should not have visible shortcuts, since they are already active. + +#### When to use `MenuItemsChoice` + +Use `MenuItemsChoice` when you want users to: + +- Select a single option from a set of choices in a menu. +- Expose all available options. + +`MenuItemsChoice` should not be used to toggle individual features on and off. For that, consider using a `FeatureToggle`. + +#### Defaults + +When using `MenuItemsChoice` , **one option should be selected by default** (i.e., when the page loads, in the case of a web application). + +**User control** + +Selecting an option by default communicates that the user is required to choose one in the set. + +**Expediting tasks** + +When one choice in a set of `MenuItemsChoice` is the most desirable or frequently selected, it’s helpful to select it by default. Doing this reduces the interaction cost and can save the user time and clicks. + +**The power of suggestion** + +Designs with a `MenuItemsChoice` option selected by default make a strong suggestion to the user. It can help them make the best decision and increase their confidence. (Use this guidance with caution, and only for good.) + + +## Development guidelines + +### Usage + +```jsx +import { MenuGroup, MenuItemsChoice } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyMenuItemsChoice = withState( { + mode: 'visual', + choices: [ + { + value: 'visual', + label: 'Visual Editor', + }, + { + value: 'text', + label: 'Code Editor', + }, + ], +} )( ( { mode, choices, setState } ) => ( + <MenuGroup label="Editor"> + <MenuItemsChoice + choices={ choices } + value={ mode } + onSelect={ mode => setState( { mode } ) } + /> + </MenuGroup> +) ); +``` diff --git a/gb-src/packages/components/src/menu-items-choice/index.js b/gb-src/packages/components/src/menu-items-choice/index.js new file mode 100644 index 0000000000000..2311fd3a0435d --- /dev/null +++ b/gb-src/packages/components/src/menu-items-choice/index.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import MenuItem from '../menu-item'; + +export default function MenuItemsChoice( { + choices = [], + onSelect, + value, +} ) { + return choices.map( ( item ) => { + const isSelected = value === item.value; + return ( + <MenuItem + key={ item.value } + role="menuitemradio" + icon={ isSelected && 'yes' } + isSelected={ isSelected } + shortcut={ item.shortcut } + onClick={ () => { + if ( ! isSelected ) { + onSelect( item.value ); + } + } } + > + { item.label } + </MenuItem> + ); + } ); +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/button.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/button.native.js new file mode 100644 index 0000000000000..cc9c5cdae22ab --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/button.native.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { TouchableOpacity, View, Text } from 'react-native'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; + +const BottomSheetButton = ( { + onPress, + disabled, + text, + color, +} ) => ( + <TouchableOpacity + accessible={ true } + onPress={ onPress } + disabled={ disabled } + > + <View style={ { flexDirection: 'row', justifyContent: 'center' } }> + <Text style={ { ...styles.buttonText, color } }> + { text } + </Text> + </View> + </TouchableOpacity> +); + +export default BottomSheetButton; diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/cell.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/cell.native.js new file mode 100644 index 0000000000000..cebfc6a91c182 --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/cell.native.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import { TouchableOpacity, Text, View, TextInput, I18nManager } from 'react-native'; +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Dashicon } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; +import platformStyles from './cellStyles.scss'; + +export default class BottomSheetCell extends Component { + constructor( props ) { + super( ...arguments ); + this.state = { + isEditingValue: props.autoFocus || false, + }; + } + + componentDidUpdate() { + if ( this.state.isEditingValue ) { + this._valueTextInput.focus(); + } + } + + render() { + const { + accessibilityLabel, + accessibilityHint, + accessibilityRole, + onPress, + label, + value, + valuePlaceholder = '', + icon, + labelStyle = {}, + valueStyle = {}, + onChangeValue, + children, + editable = true, + separatorType, + style = {}, + ...valueProps + } = this.props; + + const showValue = value !== undefined; + const isValueEditable = editable && onChangeValue !== undefined; + const defaultLabelStyle = showValue || icon !== undefined ? styles.cellLabel : styles.cellLabelCentered; + const drawSeparator = ( separatorType && separatorType !== 'none' ) || separatorStyle === undefined; + + const onCellPress = () => { + if ( isValueEditable ) { + startEditing(); + } else if ( onPress !== undefined ) { + onPress(); + } + }; + + const finishEditing = () => { + this.setState( { isEditingValue: false } ); + }; + + const startEditing = () => { + if ( this.state.isEditingValue === false ) { + this.setState( { isEditingValue: true } ); + } + }; + + const separatorStyle = () => { + const leftMarginStyle = { ...styles.cellSeparator, ...platformStyles.separatorMarginLeft }; + switch ( separatorType ) { + case 'leftMargin': + return leftMarginStyle; + case 'fullWidth': + return styles.separator; + case 'none': + return undefined; + case undefined: + return showValue ? leftMarginStyle : styles.separator; + } + }; + + const getValueComponent = () => { + const styleRTL = I18nManager.isRTL && styles.cellValueRTL; + const finalStyle = { ...styles.cellValue, ...valueStyle, ...styleRTL }; + + // To be able to show the `middle` ellipsizeMode on editable cells + // we show the TextInput just when the user wants to edit the value, + // and the Text component to display it. + // We also show the TextInput to display placeholder. + const shouldShowPlaceholder = isValueEditable && value === ''; + return this.state.isEditingValue || shouldShowPlaceholder ? ( + <TextInput + ref={ ( c ) => this._valueTextInput = c } + numberOfLines={ 1 } + style={ finalStyle } + value={ value } + placeholder={ valuePlaceholder } + placeholderTextColor={ '#87a6bc' } + onChangeText={ onChangeValue } + editable={ isValueEditable } + pointerEvents={ this.state.isEditingValue ? 'auto' : 'none' } + onFocus={ startEditing } + onBlur={ finishEditing } + { ...valueProps } + /> + ) : ( + <Text + style={ { ...styles.cellValue, ...valueStyle } } + numberOfLines={ 1 } + ellipsizeMode={ 'middle' } + > + { value } + </Text> + ); + }; + + const getAccessibilityLabel = () => { + if ( accessibilityLabel || ! showValue ) { + return accessibilityLabel || label; + } + return isEmpty( value ) ? + sprintf( + /* translators: accessibility text. Empty state of a inline textinput cell. %s: The cell's title */ + _x( '%s. Empty', 'inline textinput cell' ), + label + ) : + // Separating by ',' is necessary to make a pause on urls (non-capitalized text) + sprintf( + /* translators: accessibility text. Inline textinput title and value.%1: Cell title, %2: cell value. */ + _x( '%1$s, %2$s', 'inline textinput cell' ), + label, + value + ); + }; + + return ( + <TouchableOpacity + accessible={ ! this.state.isEditingValue } + accessibilityLabel={ getAccessibilityLabel() } + accessibilityRole={ accessibilityRole || 'button' } + accessibilityHint={ isValueEditable ? + /* translators: accessibility text */ + __( 'Double tap to edit this value' ) : + accessibilityHint + } + onPress={ onCellPress } + style={ { ...styles.clipToBounds, ...style } } + > + <View style={ styles.cellContainer }> + <View style={ styles.cellRowContainer }> + { icon && ( + <View style={ styles.cellRowContainer }> + <Dashicon icon={ icon } size={ 24 } /> + <View style={ platformStyles.labelIconSeparator } /> + </View> + ) } + <Text numberOfLines={ 1 } style={ { ...defaultLabelStyle, ...labelStyle } }> + { label } + </Text> + </View> + { showValue && getValueComponent() } + { children } + </View> + { drawSeparator && ( + <View style={ separatorStyle() } /> + ) } + </TouchableOpacity> + ); + } +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.android.scss b/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.android.scss new file mode 100644 index 0000000000000..4feb815ba9bd0 --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.android.scss @@ -0,0 +1,7 @@ +.labelIconSeparator { + width: 32px; +} + +.separatorMarginLeft { + margin-left: 56px; +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.ios.scss b/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.ios.scss new file mode 100644 index 0000000000000..45b97213d1768 --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/cellStyles.ios.scss @@ -0,0 +1,7 @@ +.labelIconSeparator { + width: 12px; +} + +.separatorMarginLeft { + margin-left: 36px; +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/index.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/index.native.js new file mode 100644 index 0000000000000..9106709cea25e --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/index.native.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { Text, View, Platform, PanResponder, Dimensions, Easing } from 'react-native'; +import Modal from 'react-native-modal'; +import SafeArea from 'react-native-safe-area'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; +import Button from './button'; +import Cell from './cell'; +import PickerCell from './picker-cell'; +import SwitchCell from './switch-cell'; +import KeyboardAvoidingView from './keyboard-avoiding-view'; + +class BottomSheet extends Component { + constructor() { + super( ...arguments ); + this.onSafeAreaInsetsUpdate = this.onSafeAreaInsetsUpdate.bind( this ); + this.state = { + safeAreaBottomInset: 0, + }; + + SafeArea.getSafeAreaInsetsForRootView().then( this.onSafeAreaInsetsUpdate ); + } + + componentDidMount() { + this.safeAreaEventSubscription = SafeArea.addEventListener( 'safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsUpdate ); + } + + componentWillUnmount() { + if ( this.safeAreaEventSubscription === null ) { + return; + } + this.safeAreaEventSubscription.remove(); + this.safeAreaEventSubscription = null; + SafeArea.removeEventListener( 'safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsUpdate ); + } + + onSafeAreaInsetsUpdate( result ) { + if ( this.safeAreaEventSubscription === null ) { + return; + } + const { safeAreaInsets } = result; + if ( this.state.safeAreaBottomInset !== safeAreaInsets.bottom ) { + this.setState( { safeAreaBottomInset: safeAreaInsets.bottom } ); + } + } + + render() { + const { + title = '', + isVisible, + leftButton, + rightButton, + hideHeader, + style = {}, + contentStyle = {}, + } = this.props; + + const panResponder = PanResponder.create( { + onMoveShouldSetPanResponder: ( evt, gestureState ) => { + // Activates swipe down over child Touchables if the swipe is long enough. + // With this we can adjust sensibility on the swipe vs tap gestures. + if ( gestureState.dy > 3 ) { + gestureState.dy = 0; + return true; + } + }, + } ); + + const getHeader = () => ( + <View> + <View style={ styles.head }> + <View style={ { flex: 1 } }> + { leftButton } + </View> + <View style={ styles.titleContainer }> + <Text style={ styles.title }> + { title } + </Text> + </View> + <View style={ { flex: 1 } }> + { rightButton } + </View> + </View> + <View style={ styles.separator } /> + </View> + ); + + const { height } = Dimensions.get( 'window' ); + const easing = Easing.bezier( 0.450, 0.000, 0.160, 1.020 ); + + const animationIn = { + easing, + from: { + translateY: height, + }, + to: { + translateY: 0, + }, + }; + + const animationOut = { + easing, + from: { + translateY: 0, + }, + to: { + translateY: height, + }, + }; + + return ( + <Modal + isVisible={ isVisible } + style={ styles.bottomModal } + animationIn={ animationIn } + animationInTiming={ 600 } + animationOut={ animationOut } + animationOutTiming={ 250 } + backdropTransitionInTiming={ 50 } + backdropTransitionOutTiming={ 50 } + backdropOpacity={ 0.2 } + onBackdropPress={ this.props.onClose } + onBackButtonPress={ this.props.onClose } + onSwipe={ this.props.onClose } + swipeDirection="down" + onMoveShouldSetResponder={ panResponder.panHandlers.onMoveShouldSetResponder } + onMoveShouldSetResponderCapture={ panResponder.panHandlers.onMoveShouldSetResponderCapture } + onAccessibilityEscape={ this.props.onClose } + > + <KeyboardAvoidingView + behavior={ Platform.OS === 'ios' && 'padding' } + style={ { ...styles.background, borderColor: 'rgba(0, 0, 0, 0.1)', ...style } } + keyboardVerticalOffset={ -this.state.safeAreaBottomInset } + > + <View style={ styles.dragIndicator } /> + { hideHeader && ( <View style={ styles.emptyHeaderSpace } /> ) } + { ! hideHeader && getHeader() } + <View style={ [ styles.content, contentStyle ] }> + { this.props.children } + </View> + <View style={ { height: this.state.safeAreaBottomInset } } /> + </KeyboardAvoidingView> + </Modal> + + ); + } +} + +function getWidth() { + return Math.min( Dimensions.get( 'window' ).width, styles.background.maxWidth ); +} + +BottomSheet.getWidth = getWidth; +BottomSheet.Button = Button; +BottomSheet.Cell = Cell; +BottomSheet.PickerCell = PickerCell; +BottomSheet.SwitchCell = SwitchCell; + +export default BottomSheet; diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/keyboard-avoiding-view.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/keyboard-avoiding-view.native.js new file mode 100644 index 0000000000000..85214b40d51c0 --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/keyboard-avoiding-view.native.js @@ -0,0 +1,118 @@ + +/** + * External dependencies + */ +import React from 'react'; +import { + Keyboard, + LayoutAnimation, + Platform, + StyleSheet, + View, + Dimensions, +} from 'react-native'; + +/** + * This is a simplified version of Facebook's KeyboardAvoidingView. + * It's meant to work specifically with BottomSheets. + * This fixes an issue in the bottom padding calculation, when the + * BottomSheet was presented on Landscape, with the keyboard already present, + * and a TextField on Autofocus (situation present on Links UI) + */ +class KeyboardAvoidingView extends React.Component { + constructor() { + super( ...arguments ); + + this._onKeyboardChange = this._onKeyboardChange.bind( this ); + this._subscriptions = []; + this.state = { + bottom: 0, + }; + } + + _relativeKeyboardHeight( keyboardFrame ) { + if ( ! keyboardFrame ) { + return 0; + } + + const windowHeight = Dimensions.get( 'window' ).height; + const keyboardY = keyboardFrame.screenY - this.props.keyboardVerticalOffset; + + const final = Math.max( windowHeight - keyboardY, 0 ); + return final; + } + + /** + * @param {Object} event Keyboard event. + */ + _onKeyboardChange( event ) { + if ( event === null ) { + this.setState( { bottom: 0 } ); + return; + } + + const { duration, easing, endCoordinates } = event; + const height = this._relativeKeyboardHeight( endCoordinates ); + + if ( this.state.bottom === height ) { + return; + } + + if ( duration && easing ) { + LayoutAnimation.configureNext( { + duration, + update: { + duration, + type: LayoutAnimation.Types[ easing ] || 'keyboard', + }, + } ); + } + this.setState( { bottom: height } ); + } + + componentDidMount() { + if ( Platform.OS === 'ios' ) { + this._subscriptions = [ + Keyboard.addListener( 'keyboardWillChangeFrame', this._onKeyboardChange ), + ]; + } + } + + componentWillUnmount() { + this._subscriptions.forEach( ( subscription ) => { + subscription.remove(); + } ); + } + + render() { + const { + children, + enabled, + keyboardVerticalOffset, // eslint-disable-line no-unused-vars + style, + ...props + } = this.props; + + let finalStyle = style; + if ( Platform.OS === 'ios' ) { + const bottomHeight = enabled ? this.state.bottom : 0; + finalStyle = StyleSheet.compose( style, { paddingBottom: bottomHeight } ); + } + + return ( + <View + style={ finalStyle } + { ...props } + > + { children } + </View> + ); + } +} + +KeyboardAvoidingView.defaultProps = { + enabled: true, + keyboardVerticalOffset: 0, +}; + +export default KeyboardAvoidingView; diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/picker-cell.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/picker-cell.native.js new file mode 100644 index 0000000000000..50a775f25728b --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/picker-cell.native.js @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import Cell from './cell'; +import Picker from '../picker'; + +export default function BottomSheetPickerCell( props ) { + const { + options, + onChangeValue, + ...cellProps + } = props; + + let picker; + + const onCellPress = () => { + picker.presentPicker(); + }; + + const onChange = ( newValue ) => { + onChangeValue( newValue ); + }; + + return ( + <Cell onPress={ onCellPress } editable={ false } { ...cellProps } > + <Picker + ref={ ( instance ) => picker = instance } + options={ options } + onChange={ onChange } + /> + </Cell> + ); +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/styles.native.scss b/gb-src/packages/components/src/mobile/bottom-sheet/styles.native.scss new file mode 100644 index 0000000000000..53764ee4fe38f --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/styles.native.scss @@ -0,0 +1,120 @@ +//Bottom Sheet + +.bottomModal { + justify-content: flex-end; + margin: 0; + align-items: center; +} + +.dragIndicator { + background-color: $light-gray-400; + height: 4px; + width: 36px; + margin: auto; + margin-top: 6px; + border-radius: 2px; +} + +.separator { + background-color: $light-gray-400; + height: 1px; + width: 100%; +} + +.emptyHeaderSpace { + height: 14; +} + +.background { + background-color: $white; + border-top-right-radius: 8px; + border-top-left-radius: 8px; + width: 100%; + max-width: 512; + padding-bottom: 0; +} + +.content { + padding: 0 16px 0 16px; +} + +.head { + flex-direction: row; + width: 100%; + margin-bottom: 5px; + justify-content: space-between; + align-items: center; + align-content: center; + min-height: 48; +} + +.title { + color: $dark-gray-600; + font-size: 18px; + font-weight: 600; + text-align: center; +} + +.titleContainer { + justify-content: center; + flex: 2; + align-content: center; +} + +// Button + +.buttonText { + font-size: 18px; + padding: 5px; +} + +// Cell + +.cellContainer { + flex-direction: row; + min-height: 48; + align-items: center; +} + +.clipToBounds { + overflow: hidden; +} + +.cellSeparator { + background-color: $light-gray-400; + height: 1px; + width: 100%; +} + +.cellRowContainer { + flex-direction: row; + align-items: center; +} + +.cellIcon { + padding-right: 0; +} + +.cellLabel { + font-size: 17px; + color: #2e4453; + margin-right: 12px; +} + +.cellLabelCentered { + font-size: 17px; + color: #2e4453; + flex: 1; + text-align: center; +} + +.cellValue { + font-size: 17px; + color: #2e4453; + text-align: right; + flex: 1; +} + +.cellValueRTL { + text-align: left; +} diff --git a/gb-src/packages/components/src/mobile/bottom-sheet/switch-cell.native.js b/gb-src/packages/components/src/mobile/bottom-sheet/switch-cell.native.js new file mode 100644 index 0000000000000..011f579849900 --- /dev/null +++ b/gb-src/packages/components/src/mobile/bottom-sheet/switch-cell.native.js @@ -0,0 +1,57 @@ + +/** + * External dependencies + */ +import { Switch } from 'react-native'; +/** + * WordPress dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import Cell from './cell'; + +export default function BottomSheetSwitchCell( props ) { + const { + value, + onValueChange, + ...cellProps + } = props; + + const onPress = () => { + onValueChange( ! value ); + }; + + const accessibilityLabel = value ? + sprintf( + /* translators: accessibility text. Switch setting ON state. %s: Switch title. */ + _x( '%s. On', 'switch control' ), + cellProps.label + ) : + sprintf( + /* translators: accessibility text. Switch setting OFF state. %s: Switch title. */ + _x( '%s. Off', 'switch control' ), + cellProps.label + ); + + return ( + <Cell + { ...cellProps } + accessibilityLabel={ accessibilityLabel } + accessibilityRole={ 'none' } + accessibilityHint={ + /* translators: accessibility text (hint for switches) */ + __( 'Double tap to toggle setting' ) + } + onPress={ onPress } + editable={ false } + value={ '' } + > + <Switch + value={ value } + onValueChange={ onValueChange } + /> + </Cell> + ); +} diff --git a/gb-src/packages/components/src/mobile/html-text-input/container.android.js b/gb-src/packages/components/src/mobile/html-text-input/container.android.js new file mode 100644 index 0000000000000..ef62fc0178a3d --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/container.android.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { ScrollView } from 'react-native'; + +/** + * Internal dependencies + */ +import KeyboardAvoidingView from '../keyboard-avoiding-view'; +import styles from './style.android.scss'; + +const HTMLInputContainer = ( { children, parentHeight } ) => ( + <KeyboardAvoidingView style={ styles.keyboardAvoidingView } parentHeight={ parentHeight }> + <ScrollView style={ styles.scrollView } > + { children } + </ScrollView> + </KeyboardAvoidingView> +); + +HTMLInputContainer.scrollEnabled = false; + +export default HTMLInputContainer; diff --git a/gb-src/packages/components/src/mobile/html-text-input/container.ios.js b/gb-src/packages/components/src/mobile/html-text-input/container.ios.js new file mode 100644 index 0000000000000..a9074311f473b --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/container.ios.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { UIManager, PanResponder } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import KeyboardAvoidingView from '../keyboard-avoiding-view'; +import styles from './style.ios.scss'; + +class HTMLInputContainer extends Component { + constructor() { + super( ...arguments ); + + this.panResponder = PanResponder.create( { + onStartShouldSetPanResponderCapture: () => true, + + onPanResponderMove: ( e, gestureState ) => { + if ( gestureState.dy > 100 && gestureState.dy < 110 ) { + //Keyboard.dismiss() and this.textInput.blur() are not working here + //They require to know the currentlyFocusedID under the hood but + //during this gesture there's no currentlyFocusedID + UIManager.blur( e.target ); + } + }, + } ); + } + + render() { + return ( + <KeyboardAvoidingView + style={ styles.keyboardAvoidingView } + { ...this.panResponder.panHandlers } + parentHeight={ this.props.parentHeight } + > + { this.props.children } + </KeyboardAvoidingView> + ); + } +} + +HTMLInputContainer.scrollEnabled = true; + +export default HTMLInputContainer; diff --git a/gb-src/packages/components/src/mobile/html-text-input/index.native.js b/gb-src/packages/components/src/mobile/html-text-input/index.native.js new file mode 100644 index 0000000000000..e25f7a1af71c4 --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/index.native.js @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { TextInput } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { parse } from '@wordpress/blocks'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import HTMLInputContainer from './container'; +import styles from './style.scss'; + +export class HTMLTextInput extends Component { + constructor() { + super( ...arguments ); + + this.edit = this.edit.bind( this ); + this.stopEditing = this.stopEditing.bind( this ); + + this.state = { + isDirty: false, + value: '', + }; + } + + static getDerivedStateFromProps( props, state ) { + if ( state.isDirty ) { + return null; + } + + return { + value: props.value, + isDirty: false, + }; + } + + componentWillUnmount() { + //TODO: Blocking main thread + this.stopEditing(); + } + + edit( html ) { + this.props.onChange( html ); + this.setState( { value: html, isDirty: true } ); + } + + stopEditing() { + if ( this.state.isDirty ) { + this.props.onPersist( this.state.value ); + this.setState( { isDirty: false } ); + } + } + + render() { + return ( + <HTMLInputContainer parentHeight={ this.props.parentHeight }> + <TextInput + autoCorrect={ false } + accessibilityLabel="html-view-title" + textAlignVertical="center" + numberOfLines={ 1 } + style={ styles.htmlViewTitle } + value={ this.props.title } + placeholder={ __( 'Add title' ) } + onChangeText={ this.props.editTitle } + /> + <TextInput + autoCorrect={ false } + accessibilityLabel="html-view-content" + textAlignVertical="top" + multiline + style={ styles.htmlView } + value={ this.state.value } + onChangeText={ this.edit } + onBlur={ this.stopEditing } + placeholder={ __( 'Start writing…' ) } + scrollEnabled={ HTMLInputContainer.scrollEnabled } + /> + </HTMLInputContainer> + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + getEditedPostAttribute, + getEditedPostContent, + } = select( 'core/editor' ); + + return { + title: getEditedPostAttribute( 'title' ), + value: getEditedPostContent(), + }; + } ), + withDispatch( ( dispatch ) => { + const { resetBlocks } = dispatch( 'core/block-editor' ); + const { editPost } = dispatch( 'core/editor' ); + return { + editTitle( title ) { + editPost( { title } ); + }, + onChange( content ) { + editPost( { content } ); + }, + onPersist( content ) { + resetBlocks( parse( content ) ); + }, + }; + } ), + withInstanceId, +] )( HTMLTextInput ); diff --git a/gb-src/packages/components/src/mobile/html-text-input/style-common.native.scss b/gb-src/packages/components/src/mobile/html-text-input/style-common.native.scss new file mode 100644 index 0000000000000..4db5b98516140 --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/style-common.native.scss @@ -0,0 +1,15 @@ +$padding: 8; +$backgroundColor: $white; +$htmlFont: $default-monospace-font; + +.keyboardAvoidingView { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; +} + +.container { + flex: 1; +} diff --git a/gb-src/packages/components/src/mobile/html-text-input/style.android.scss b/gb-src/packages/components/src/mobile/html-text-input/style.android.scss new file mode 100644 index 0000000000000..10594358722c3 --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/style.android.scss @@ -0,0 +1,23 @@ +@import "./style-common.scss"; + +.htmlView { + font-family: $htmlFont; + background-color: $backgroundColor; + padding-left: $padding; + padding-right: $padding; + padding-top: $padding; + padding-bottom: $padding + 16; +} + +.htmlViewTitle { + font-family: $htmlFont; + background-color: $backgroundColor; + padding-left: $padding; + padding-right: $padding; + padding-top: $padding; + padding-bottom: $padding; +} + +.scrollView { + flex: 1; +} diff --git a/gb-src/packages/components/src/mobile/html-text-input/style.ios.scss b/gb-src/packages/components/src/mobile/html-text-input/style.ios.scss new file mode 100644 index 0000000000000..8b13392b95a9a --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/style.ios.scss @@ -0,0 +1,21 @@ +@import "./style-common.scss"; + +$title-height: 32; + +.htmlView { + font-family: $htmlFont; + background-color: $backgroundColor; + padding-left: $padding; + padding-right: $padding; + padding-bottom: $title-height + $padding; +} + +.htmlViewTitle { + font-family: $htmlFont; + background-color: $backgroundColor; + padding-left: $padding; + padding-right: $padding; + padding-top: $padding; + padding-bottom: $padding; + height: $title-height; +} diff --git a/gb-src/packages/components/src/mobile/html-text-input/test/index.native.js b/gb-src/packages/components/src/mobile/html-text-input/test/index.native.js new file mode 100644 index 0000000000000..479846d3f6a96 --- /dev/null +++ b/gb-src/packages/components/src/mobile/html-text-input/test/index.native.js @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { HTMLTextInput } from '..'; + +// Utility to find a TextInput in a ShallowWrapper +const findTextInputInWrapper = ( wrapper, matchingProps ) => { + return wrapper.dive().findWhere( ( node ) => { + return node.name() === 'TextInput' && node.is( matchingProps ); + } ).first(); +}; + +// Finds the Content TextInput in our HTMLInputView +const findContentTextInput = ( wrapper ) => { + const placeholder = __( 'Start writing…' ); + const matchingProps = { multiline: true, placeholder }; + return findTextInputInWrapper( wrapper, matchingProps ); +}; + +// Finds the Title TextInput in our HTMLInputView +const findTitleTextInput = ( wrapper ) => { + const placeholder = __( 'Add title' ); + return findTextInputInWrapper( wrapper, { placeholder } ); +}; + +describe( 'HTMLTextInput', () => { + it( 'HTMLTextInput renders', () => { + const wrapper = shallow( + <HTMLTextInput /> + ); + expect( wrapper ).toBeTruthy(); + } ); + + it( 'HTMLTextInput updates store and state on HTML text change', () => { + const onChange = jest.fn(); + + const wrapper = shallow( + <HTMLTextInput + onChange={ onChange } + /> + ); + + expect( wrapper.instance().state.isDirty ).toBeFalsy(); + + // Simulate user typing text + const htmlTextInput = findContentTextInput( wrapper ); + htmlTextInput.simulate( 'changeText', 'text' ); + + //Check if the onChange is called and the state is updated + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( 'text' ); + + expect( wrapper.instance().state.isDirty ).toBeTruthy(); + expect( wrapper.instance().state.value ).toEqual( 'text' ); + } ); + + it( 'HTMLTextInput persists changes in HTML text input on blur', () => { + const onPersist = jest.fn(); + + const wrapper = shallow( + <HTMLTextInput + onPersist={ onPersist } + onChange={ jest.fn() } + /> + ); + + // Simulate user typing text + const htmlTextInput = findContentTextInput( wrapper ); + htmlTextInput.simulate( 'changeText', 'text' ); + + //Simulate blur event + htmlTextInput.simulate( 'blur' ); + + //Normally prop.value is updated with the help of withSelect + //But we don't have it in tests so we just simulate it + wrapper.setProps( { value: 'text' } ); + + //Check if the onPersist is called and the state is updated + expect( onPersist ).toHaveBeenCalledTimes( 1 ); + expect( onPersist ).toHaveBeenCalledWith( 'text' ); + + expect( wrapper.instance().state.isDirty ).toBeFalsy(); + + //We expect state.value is getting propagated from prop.value + expect( wrapper.instance().state.value ).toEqual( 'text' ); + } ); + + it( 'HTMLTextInput propagates title changes to store', () => { + const editTitle = jest.fn(); + + const wrapper = shallow( + <HTMLTextInput + editTitle={ editTitle } + /> + ); + + // Simulate user typing text + const textInput = findTitleTextInput( wrapper ); + textInput.simulate( 'changeText', 'text' ); + + //Check if the setTitleAction is called + expect( editTitle ).toHaveBeenCalledTimes( 1 ); + expect( editTitle ).toHaveBeenCalledWith( 'text' ); + } ); +} ); + diff --git a/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.android.js b/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.android.js new file mode 100644 index 0000000000000..10641b91b73cd --- /dev/null +++ b/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.android.js @@ -0,0 +1,8 @@ +/** + * External dependencies + */ +import { KeyboardAvoidingView as AndroidKeyboardAvoidingView } from 'react-native'; + +export const KeyboardAvoidingView = AndroidKeyboardAvoidingView; + +export default KeyboardAvoidingView; diff --git a/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.ios.js b/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.ios.js new file mode 100644 index 0000000000000..55fac41b263e9 --- /dev/null +++ b/gb-src/packages/components/src/mobile/keyboard-avoiding-view/index.ios.js @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { KeyboardAvoidingView as IOSKeyboardAvoidingView, Dimensions } from 'react-native'; + +export const KeyboardAvoidingView = ( { parentHeight, ...otherProps } ) => { + const { height: fullHeight } = Dimensions.get( 'window' ); + const keyboardVerticalOffset = fullHeight - parentHeight; + + return ( + <IOSKeyboardAvoidingView { ...otherProps } behavior="padding" keyboardVerticalOffset={ keyboardVerticalOffset } /> + ); +}; + +export default KeyboardAvoidingView; diff --git a/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js b/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js new file mode 100644 index 0000000000000..0774c845ebcf5 --- /dev/null +++ b/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { FlatList } from 'react-native'; + +/** + * Internal dependencies + */ +import KeyboardAvoidingView from '../keyboard-avoiding-view'; + +export const KeyboardAwareFlatList = ( props ) => ( + <KeyboardAvoidingView style={ { flex: 1 } }> + <FlatList { ...props } /> + </KeyboardAvoidingView> +); + +KeyboardAwareFlatList.handleCaretVerticalPositionChange = () => { + //no need to handle on Android, it is system managed +}; + +export default KeyboardAwareFlatList; diff --git a/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js b/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js new file mode 100644 index 0000000000000..fb9dab39a03f2 --- /dev/null +++ b/gb-src/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +import { FlatList } from 'react-native'; + +export const KeyboardAwareFlatList = ( { + extraScrollHeight, + shouldPreventAutomaticScroll, + innerRef, + ...listProps +} ) => ( + <KeyboardAwareScrollView + style={ { flex: 1 } } + keyboardDismissMode="none" + enableResetScrollToCoords={ false } + keyboardShouldPersistTaps="handled" + extraScrollHeight={ extraScrollHeight } + extraHeight={ 0 } + innerRef={ ( ref ) => { + this.scrollViewRef = ref; + innerRef( ref ); + } } + onKeyboardWillHide={ () => { + this.keyboardWillShowIndicator = false; + } } + onKeyboardDidHide={ () => { + setTimeout( () => { + if ( ! this.keyboardWillShowIndicator && + this.latestContentOffsetY !== undefined && + ! shouldPreventAutomaticScroll() ) { + // Reset the content position if keyboard is still closed + this.scrollViewRef.props.scrollToPosition( 0, this.latestContentOffsetY, true ); + } + }, 50 ); + } } + onKeyboardWillShow={ () => { + this.keyboardWillShowIndicator = true; + } } + onScroll={ ( event ) => { + this.latestContentOffsetY = event.nativeEvent.contentOffset.y; + } } > + <FlatList { ...listProps } /> + </KeyboardAwareScrollView> +); + +KeyboardAwareFlatList.handleCaretVerticalPositionChange = ( scrollView, targetId, caretY, previousCaretY ) => { + if ( previousCaretY ) { //if this is not the first tap + scrollView.props.refreshScrollForField( targetId ); + } +}; + +export default KeyboardAwareFlatList; + diff --git a/gb-src/packages/components/src/mobile/picker/index.android.js b/gb-src/packages/components/src/mobile/picker/index.android.js new file mode 100644 index 0000000000000..3fad1cc2b23bf --- /dev/null +++ b/gb-src/packages/components/src/mobile/picker/index.android.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import React from 'react'; +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BottomSheet from '../bottom-sheet'; + +export default class Picker extends Component { + constructor() { + super( ...arguments ); + this.onClose = this.onClose.bind( this ); + this.onCellPress = this.onCellPress.bind( this ); + + this.state = { + isVisible: false, + }; + } + + presentPicker() { + this.setState( { isVisible: true } ); + } + + onClose() { + this.setState( { isVisible: false } ); + } + + onCellPress( value ) { + this.props.onChange( value ); + this.onClose(); + } + + render() { + return ( + <BottomSheet + isVisible={ this.state.isVisible } + onClose={ this.onClose } + style={ { paddingBottom: 20 } } + hideHeader + > + <View> + { this.props.options.map( ( option, index ) => + <BottomSheet.Cell + icon={ option.icon } + key={ index } + label={ option.label } + separatorType={ 'none' } + onPress={ () => this.onCellPress( option.value ) } + /> + ) } + { ! this.props.hideCancelButton && <BottomSheet.Cell + label={ __( 'Cancel' ) } + onPress={ this.onClose } + separatorType={ 'none' } + /> } + </View> + </BottomSheet> + ); + } +} diff --git a/gb-src/packages/components/src/mobile/picker/index.ios.js b/gb-src/packages/components/src/mobile/picker/index.ios.js new file mode 100644 index 0000000000000..2cf9798b28e9c --- /dev/null +++ b/gb-src/packages/components/src/mobile/picker/index.ios.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { ActionSheetIOS } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; + +class Picker extends Component { + presentPicker() { + const { options, onChange } = this.props; + const labels = options.map( ( { label } ) => label ); + const fullOptions = [ __( 'Cancel' ) ].concat( labels ); + + ActionSheetIOS.showActionSheetWithOptions( + { + options: fullOptions, + cancelButtonIndex: 0, + }, + ( buttonIndex ) => { + if ( buttonIndex === 0 ) { + return; + } + const selected = options[ buttonIndex - 1 ]; + onChange( selected.value ); + }, + ); + } + + render() { + return null; + } +} + +export default Picker; diff --git a/gb-src/packages/components/src/mobile/readable-content-view/index.native.js b/gb-src/packages/components/src/mobile/readable-content-view/index.native.js new file mode 100644 index 0000000000000..dbeb172ab0434 --- /dev/null +++ b/gb-src/packages/components/src/mobile/readable-content-view/index.native.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { View, Dimensions } from 'react-native'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +const ReadableContentView = ( { reversed, children } ) => ( + <View style={ styles.container } > + <View style={ reversed ? styles.reversedCenteredContent : styles.centeredContent } > + { children } + </View> + </View> +); + +const isContentMaxWidth = () => { + const { width } = Dimensions.get( 'window' ); + return width > styles.centeredContent.maxWidth; +}; + +ReadableContentView.isContentMaxWidth = isContentMaxWidth; + +export default ReadableContentView; diff --git a/gb-src/packages/components/src/mobile/readable-content-view/style.native.scss b/gb-src/packages/components/src/mobile/readable-content-view/style.native.scss new file mode 100644 index 0000000000000..61097fc4a2bbd --- /dev/null +++ b/gb-src/packages/components/src/mobile/readable-content-view/style.native.scss @@ -0,0 +1,14 @@ +.container { + align-items: center; +} + +.centeredContent { + width: 100%; + max-width: 580; +} + +.reversedCenteredContent { + flex-direction: column-reverse; + width: 100%; + max-width: 580; +} diff --git a/gb-src/packages/components/src/modal/README.md b/gb-src/packages/components/src/modal/README.md new file mode 100644 index 0000000000000..3c96c6a5f6592 --- /dev/null +++ b/gb-src/packages/components/src/modal/README.md @@ -0,0 +1,243 @@ +# Modal + +Modals give users information and choices related to a task they’re trying to accomplish. They can contain critical information, require decisions, or involve multiple tasks. + +![An alert modal for trashing a post](https://wordpress.org/gutenberg/files/2019/04/Modal.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +A modal is a type of floating window that appears in front of content to provide critical information or ask for a decision. Modals disable all other functionality when they appear. A modal remains on screen until the user confirms it, dismisses it, or takes the required action. + +While modals can be an effective way to disclose additional controls or information, they can also be a source of interruption for the user. For this reason, always question whether a modal is necessary, and work to avoid the situations in which they are required. + +#### Principles + +- **Focused**. Modals pull user attention away from the rest of the screen to focus their attention, ensuring that the modal’s content is addressed. +- **Direct**. Modal text should communicate important information and be dedicated to helping the user appropriately complete a task. +- **Helpful**. Modals should appear in response to a user task or an action to offer relevant and contextual information. + +#### When to use + +Modals are used for: + +- Errors that block normal operation. +- Critical information that requires a specific user task, decision, or acknowledgement. +- Contextual information that appears in response to a user task or action. + +### Anatomy + +![A modal diagram with labels](https://wordpress.org/gutenberg/files/2019/04/Modal-diagram.png) + +1. Container +2. Title (optional) +3. Supporting text +4. Buttons +5. Scrim +6. Close button (optional) + +### Modal box and scrim + +A modal is a type of window. Access to the rest of the UI is disabled until the modal is addressed. All modals are interruptive by design – their purpose is to have the user focus on content, so the modal surface appears in front of all other surfaces. + +To clarify that the rest of the screen is inaccessible and to focus attention on the modal, surfaces behind the modal are scrimmed — they get a temporary overlay to obscure their content and make it less prominent. + +### Title + +A modal’s purpose is communicated through its title and button text. + +Titles should: + +- Contain a brief, clear statement or question +- Avoid apologies (“Sorry for the interruption”), alarm (“Warning!”), or ambiguity (“Are you sure?”). + +![A modal that asks "Trash post?"](https://wordpress.org/gutenberg/files/2019/04/Modal-do-1.png) + +**Do** +This modal title poses a specific question, concisely explains the purpose the request, and provides clear actions. + +![A modal that asks "Are you sure?"](https://wordpress.org/gutenberg/files/2019/04/Modal-dont-1.png) + +**Don’t** +This modal creates ambiguity, and therefore unease — it leaves the user unsure about how to respond, or causes them to second-guess their answer. + +### Buttons + +#### Side-by-side buttons (recommended) + +Side-by-side buttons display two text buttons next to one another. + +![A modal with two buttons next to each other](https://wordpress.org/gutenberg/files/2019/04/Modal-buttons.png) + +#### Stacked or full-width buttons + +Use stacked buttons when you need to accommodate longer button text. Always place confirming actions above dismissive actions. + +![A modal with two buttons stacked on top of each other](https://wordpress.org/gutenberg/files/2019/04/Modal-buttons-stacked.png) + +### Behavior + +Modals appear without warning, requiring users to stop their current task. They should be used sparingly — not every choice or setting warrants this kind of abrupt interruption. + +#### Position + +Modals retain focus until dismissed or the user completes an action, like choosing a setting. They shouldn’t be obscured by other elements or appear partially on screen. + +#### Scrolling + +Most modal content should avoid scrolling. Scrolling is permissible if the modal content exceeds the height of the modal (e.g. a list component with many rows). When a modal scrolls, the modal title is pinned at the top and the buttons are pinned at the bottom. This ensures that content remains visible alongside the title and buttons, even while scrolling. + +Modals don’t scroll with elements outside of the modal, like the background. + +When viewing a scrollable list of options, the modal title and buttons remain fixed. + +#### Dismissing modals + +Modals are dismissible in three ways: + +- Tapping outside of the modal +- Tapping the “Cancel” button +- Tapping the “Close” icon button, or pressing the `esc` key + +If the user’s ability to dismiss a modal is disabled, they must choose a modal action to proceed. + +## Development guidelines + +The modal is used to create an accessible modal over an application. + +**Note:** The API for this modal has been mimicked to resemble [`react-modal`](https://github.com/reactjs/react-modal). + +### Usage + +The following example shows you how to properly implement a modal. For the modal to properly work it's important you implement the close logic for the modal properly. + +```jsx +import { Button, Modal } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyModal = withState( { + isOpen: false, +} )( ( { isOpen, setState } ) => ( + <div> + <Button isDefault onClick={ () => setState( { isOpen: true } ) }>Open Modal</Button> + { isOpen && ( + <Modal + title="This is my modal" + onRequestClose={ () => setState( { isOpen: false } ) }> + <Button isDefault onClick={ () => setState( { isOpen: false } ) }> + My custom close button + </Button> + </Modal> + ) } + </div> +) ); +``` + +### Props + +The set of props accepted by the component will be specified below. +Props not included in this set will be applied to the input elements. + +#### title + +This property is used as the modal header's title. It is required for accessibility reasons. + +- Type: `String` +- Required: Yes + +#### onRequestClose + +This function is called to indicate that the modal should be closed. + +- Type: `function` +- Required: Yes + +#### contentLabel + +If this property is added, it will be added to the modal content `div` as `aria-label`. +You are encouraged to use this if `aria.labelledby` is not provided. + +- Type: `String` +- Required: No + +#### aria.labelledby + +If this property is added, it will be added to the modal content `div` as `aria-labelledby`. +You are encouraged to use this when the modal is visually labelled. + +- Type: `String` +- Required: No +- Default: `modal-heading` + +#### aria.describedby + +If this property is added, it will be added to the modal content `div` as `aria-describedby`. + +- Type: `String` +- Required: No + +#### focusOnMount + +If this property is true, it will focus the first tabbable element rendered in the modal. + +- Type: `boolean` +- Required: No +- Default: true + +#### shouldCloseOnEsc + +If this property is added, it will determine whether the modal requests to close when the escape key is pressed. + +- Type: `boolean` +- Required: No +- Default: true + +#### shouldCloseOnClickOutside + +If this property is added, it will determine whether the modal requests to close when a mouse click occurs outside of the modal content. + +- Type: `boolean` +- Required: No +- Default: true + +#### isDismissable + +If this property is set to false, the modal will not display a close icon and cannot be dismissed. + +- Type: `boolean` +- Required: No +- Default: true + +#### className + +If this property is added, it will an additional class name to the modal content `div`. + +- Type: `String` +- Required: No + +#### role + +If this property is added, it will override the default role of the modal. + +- Type: `String` +- Required: No +- Default: `dialog` + +#### overlayClassName + +If this property is added, it will an additional class name to the modal overlay `div`. + +- Type: `String` +- Required: No + + +## Related components + +- To notify a user with a mesage of medium importance, use `Notice`. diff --git a/gb-src/packages/components/src/modal/aria-helper.js b/gb-src/packages/components/src/modal/aria-helper.js new file mode 100644 index 0000000000000..8b714b147d55e --- /dev/null +++ b/gb-src/packages/components/src/modal/aria-helper.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { forEach } from 'lodash'; + +const LIVE_REGION_ARIA_ROLES = new Set( [ + 'alert', + 'status', + 'log', + 'marquee', + 'timer', +] ); + +let hiddenElements = [], + isHidden = false; + +/** + * Hides all elements in the body element from screen-readers except + * the provided element and elements that should not be hidden from + * screen-readers. + * + * The reason we do this is because `aria-modal="true"` currently is bugged + * in Safari, and support is spotty in other browsers overall. In the future + * we should consider removing these helper functions in favor of + * `aria-modal="true"`. + * + * @param {Element} unhiddenElement The element that should not be hidden. + */ +export function hideApp( unhiddenElement ) { + if ( isHidden ) { + return; + } + const elements = document.body.children; + forEach( elements, ( element ) => { + if ( + element === unhiddenElement + ) { + return; + } + if ( elementShouldBeHidden( element ) ) { + element.setAttribute( 'aria-hidden', 'true' ); + hiddenElements.push( element ); + } + } ); + isHidden = true; +} + +/** + * Determines if the passed element should not be hidden from screen readers. + * + * @param {HTMLElement} element The element that should be checked. + * + * @return {boolean} Whether the element should not be hidden from screen-readers. + */ +export function elementShouldBeHidden( element ) { + const role = element.getAttribute( 'role' ); + return ! ( + element.tagName === 'SCRIPT' || + element.hasAttribute( 'aria-hidden' ) || + element.hasAttribute( 'aria-live' ) || + LIVE_REGION_ARIA_ROLES.has( role ) + ); +} + +/** + * Makes all elements in the body that have been hidden by `hideApp` + * visible again to screen-readers. + */ +export function showApp() { + if ( ! isHidden ) { + return; + } + forEach( hiddenElements, ( element ) => { + element.removeAttribute( 'aria-hidden' ); + } ); + hiddenElements = []; + isHidden = false; +} diff --git a/gb-src/packages/components/src/modal/frame.js b/gb-src/packages/components/src/modal/frame.js new file mode 100644 index 0000000000000..286171a06e0ca --- /dev/null +++ b/gb-src/packages/components/src/modal/frame.js @@ -0,0 +1,149 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ + +import { Component, createRef } from '@wordpress/element'; +import { ESCAPE } from '@wordpress/keycodes'; +import { focus } from '@wordpress/dom'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import IsolatedEventContainer from '../isolated-event-container'; +import withFocusOutside from '../higher-order/with-focus-outside'; +import withFocusReturn from '../higher-order/with-focus-return'; +import withConstrainedTabbing from '../higher-order/with-constrained-tabbing'; + +class ModalFrame extends Component { + constructor() { + super( ...arguments ); + + this.containerRef = createRef(); + this.handleKeyDown = this.handleKeyDown.bind( this ); + this.handleFocusOutside = this.handleFocusOutside.bind( this ); + this.focusFirstTabbable = this.focusFirstTabbable.bind( this ); + } + + /** + * Focuses the first tabbable element when props.focusOnMount is true. + */ + componentDidMount() { + // Focus on mount + if ( this.props.focusOnMount ) { + this.focusFirstTabbable(); + } + } + + /** + * Focuses the first tabbable element. + */ + focusFirstTabbable() { + const tabbables = focus.tabbable.find( this.containerRef.current ); + if ( tabbables.length ) { + tabbables[ 0 ].focus(); + } + } + + /** + * Callback function called when clicked outside the modal. + * + * @param {Object} event Mouse click event. + */ + handleFocusOutside( event ) { + if ( this.props.shouldCloseOnClickOutside ) { + this.onRequestClose( event ); + } + } + + /** + * Callback function called when a key is pressed. + * + * @param {KeyboardEvent} event Key down event. + */ + handleKeyDown( event ) { + if ( event.keyCode === ESCAPE ) { + this.handleEscapeKeyDown( event ); + } + } + + /** + * Handles a escape key down event. + * + * Calls onRequestClose and prevents propagation of the event outside the modal. + * + * @param {Object} event Key down event. + */ + handleEscapeKeyDown( event ) { + if ( this.props.shouldCloseOnEsc ) { + event.stopPropagation(); + this.onRequestClose( event ); + } + } + + /** + * Calls the onRequestClose callback props when it is available. + * + * @param {Object} event Event object. + */ + onRequestClose( event ) { + const { onRequestClose } = this.props; + if ( onRequestClose ) { + onRequestClose( event ); + } + } + + /** + * Renders the modal frame element. + * + * @return {WPElement} The modal frame element. + */ + render() { + const { + overlayClassName, + contentLabel, + aria: { + describedby, + labelledby, + }, + children, + className, + role, + style, + } = this.props; + + return ( + <IsolatedEventContainer + className={ classnames( 'components-modal__screen-overlay', overlayClassName ) } + onKeyDown={ this.handleKeyDown } + > + <div + className={ classnames( + 'components-modal__frame', + className + ) } + style={ style } + ref={ this.containerRef } + role={ role } + aria-label={ contentLabel } + aria-labelledby={ contentLabel ? null : labelledby } + aria-describedby={ describedby } + tabIndex="-1" + > + { children } + </div> + </IsolatedEventContainer> + ); + } +} + +export default compose( [ + withFocusReturn, + withConstrainedTabbing, + withFocusOutside, +] )( ModalFrame ); diff --git a/gb-src/packages/components/src/modal/header.js b/gb-src/packages/components/src/modal/header.js new file mode 100644 index 0000000000000..0e92567ae54ef --- /dev/null +++ b/gb-src/packages/components/src/modal/header.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; + +const ModalHeader = ( { icon, title, onClose, closeLabel, headingId, isDismissable } ) => { + const label = closeLabel ? closeLabel : __( 'Close dialog' ); + + return ( + <div + className="components-modal__header" + > + <div className="components-modal__header-heading-container"> + { icon && + <span className="components-modal__icon-container" aria-hidden> + { icon } + </span> + } + { title && + <h1 id={ headingId } + className="components-modal__header-heading"> + { title } + </h1> + } + </div> + { isDismissable && + <IconButton + onClick={ onClose } + icon="no-alt" + label={ label } + /> + } + </div> + ); +}; + +export default ModalHeader; diff --git a/gb-src/packages/components/src/modal/index.js b/gb-src/packages/components/src/modal/index.js new file mode 100644 index 0000000000000..07d2109443b61 --- /dev/null +++ b/gb-src/packages/components/src/modal/index.js @@ -0,0 +1,162 @@ +/** + * WordPress dependencies + */ +import { Component, createPortal } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import ModalFrame from './frame'; +import ModalHeader from './header'; +import * as ariaHelper from './aria-helper'; + +// Used to count the number of open modals. +let parentElement, + openModalCount = 0; + +class Modal extends Component { + constructor( props ) { + super( props ); + this.prepareDOM(); + } + + /** + * Appends the modal's node to the DOM, so the portal can render the + * modal in it. Also calls the openFirstModal when this is the first modal to be + * opened. + */ + componentDidMount() { + openModalCount++; + + if ( openModalCount === 1 ) { + this.openFirstModal(); + } + } + + /** + * Removes the modal's node from the DOM. Also calls closeLastModal when this is + * the last modal to be closed. + */ + componentWillUnmount() { + openModalCount--; + + if ( openModalCount === 0 ) { + this.closeLastModal(); + } + + this.cleanDOM(); + } + + /** + * Prepares the DOM for the modals to be rendered. + * + * Every modal is mounted in a separate div appended to a parent div + * that is appended to the document body. + * + * The parent div will be created if it does not yet exist, and the + * separate div for this specific modal will be appended to that. + */ + prepareDOM() { + if ( ! parentElement ) { + parentElement = document.createElement( 'div' ); + document.body.appendChild( parentElement ); + } + this.node = document.createElement( 'div' ); + parentElement.appendChild( this.node ); + } + + /** + * Removes the specific mounting point for this modal from the DOM. + */ + cleanDOM() { + parentElement.removeChild( this.node ); + } + + /** + * Prepares the DOM for this modal and any additional modal to be mounted. + * + * It appends an additional div to the body for the modals to be rendered in, + * it hides any other elements from screen-readers and adds an additional class + * to the body to prevent scrolling while the modal is open. + */ + openFirstModal() { + ariaHelper.hideApp( parentElement ); + document.body.classList.add( this.props.bodyOpenClassName ); + } + + /** + * Cleans up the DOM after the last modal is closed and makes the app available + * for screen-readers again. + */ + closeLastModal() { + document.body.classList.remove( this.props.bodyOpenClassName ); + ariaHelper.showApp(); + } + + /** + * Renders the modal. + * + * @return {WPElement} The modal element. + */ + render() { + const { + onRequestClose, + title, + icon, + closeButtonLabel, + children, + aria, + instanceId, + isDismissable, + // Many of the documented props for Modal are passed straight through + // to the ModalFrame component and handled there. + ...otherProps + } = this.props; + + const headingId = aria.labelledby || `components-modal-header-${ instanceId }`; + + // Disable reason: this stops mouse events from triggering tooltips and + // other elements underneath the modal overlay. + return createPortal( + <ModalFrame + onRequestClose={ onRequestClose } + aria={ { + labelledby: title ? headingId : null, + describedby: aria.describedby, + } } + { ...otherProps } + > + <div className={ 'components-modal__content' } tabIndex="0"> + <ModalHeader + closeLabel={ closeButtonLabel } + headingId={ headingId } + icon={ icon } + isDismissable={ isDismissable } + onClose={ onRequestClose } + title={ title } + /> + { children } + </div> + </ModalFrame>, + this.node + ); + } +} + +Modal.defaultProps = { + bodyOpenClassName: 'modal-open', + role: 'dialog', + title: null, + focusOnMount: true, + shouldCloseOnEsc: true, + shouldCloseOnClickOutside: true, + isDismissable: true, + /* accessibility */ + aria: { + labelledby: null, + describedby: null, + }, +}; + +export default withInstanceId( Modal ); diff --git a/gb-src/packages/components/src/modal/style.scss b/gb-src/packages/components/src/modal/style.scss new file mode 100644 index 0000000000000..8282698775207 --- /dev/null +++ b/gb-src/packages/components/src/modal/style.scss @@ -0,0 +1,129 @@ +// The scrim behind the modal window. +.components-modal__screen-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba($black, 0.7); + z-index: z-index(".components-modal__screen-overlay"); + + // This animates the appearance of the white background. + @include edit-post__fade-in-animation(); +} + +// The modal window element. +.components-modal__frame { + // On small screens the content needs to be full width because of limited + // space. + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + margin: 0; + border: $border-width solid $light-gray-500; + background: $white; + box-shadow: $shadow-modal; + overflow: auto; + + // Show a centered modal on bigger screens. + @include break-small() { + top: 50%; + right: auto; + bottom: auto; + left: 50%; + min-width: $modal-min-width; + max-width: calc(100% - #{ $grid-size-large } - #{ $grid-size-large }); + max-height: calc(100% - #{ $header-height } - #{ $header-height }); + transform: translate(-50%, -50%); + + // Animate the modal frame/contents appearing on the page. + animation: components-modal__appear-animation 0.1s ease-out; + animation-fill-mode: forwards; + @include reduce-motion("animation"); + } +} + +@keyframes components-modal__appear-animation { + from { + margin-top: $grid-size * 4; + } + to { + margin-top: 0; + } +} + +// Fix header to the top so it is always there to provide context to the modal +// if the content needs to be scrolled (for example, on the keyboard shortcuts +// modal screen). +.components-modal__header { + box-sizing: border-box; + border-bottom: $border-width solid $light-gray-500; + padding: 0 $grid-size-xlarge; + display: flex; + flex-direction: row; + justify-content: space-between; + background: $white; + align-items: center; + height: $header-height; + position: sticky; + top: 0; + z-index: z-index(".components-modal__header"); + margin: 0 -#{$grid-size-xlarge} $grid-size-xlarge; + + // Rules inside this query are only run by Microsoft Edge. + // Edge has bugs around position: sticky;, so it needs a separate top rule. + // See also https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/17555420/. + @supports (-ms-ime-align:auto) { + position: fixed; + width: 100%; + } + + .components-modal__header-heading { + font-size: 1rem; + font-weight: 600; + } + + h1 { + line-height: 1; + margin: 0; + } + + .components-icon-button { + position: relative; + left: $grid-size; + } +} + +.components-modal__header-heading-container { + align-items: center; + flex-grow: 1; + display: flex; + flex-direction: row; + justify-content: left; +} + +.components-modal__header-icon-container { + display: inline-block; + + svg { + max-width: $icon-button-size; + max-height: $icon-button-size; + padding: $grid-size; + } +} + +// Modal contents. +.components-modal__content { + box-sizing: border-box; + height: 100%; + padding: 0 $grid-size-xlarge $grid-size-xlarge; + + // Rules inside this query are only run by Microsoft Edge. + // This is a companion top padding to the fixed rule in line 77. + @supports (-ms-ime-align:auto) { + padding-top: $header-height; + } +} diff --git a/gb-src/packages/components/src/modal/test/aria-helper.js b/gb-src/packages/components/src/modal/test/aria-helper.js new file mode 100644 index 0000000000000..0ee9b0b947aa5 --- /dev/null +++ b/gb-src/packages/components/src/modal/test/aria-helper.js @@ -0,0 +1,69 @@ +/** + * Internal dependencies + */ +import { elementShouldBeHidden } from '../aria-helper'; + +describe( 'aria-helper', () => { + describe( 'elementShouldBeHidden', () => { + it( 'should return true when a div element without attributes is passed', () => { + const element = document.createElement( 'div' ); + + expect( elementShouldBeHidden( element ) ).toBe( true ); + } ); + + it( 'should return false when a script element without attributes is passed', () => { + const element = document.createElement( 'script' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the aria-hidden attribute with value "true"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'aria-hidden', 'true' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the aria-hidden attribute with value "false"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'aria-hidden', 'false' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the role attribute with value "alert"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'role', 'alert' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the role attribute with value "status"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'role', 'status' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the role attribute with value "log"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'role', 'log' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the role attribute with value "marquee"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'role', 'marquee' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + + it( 'should return false when an element has the role attribute with value "timer"', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'role', 'timer' ); + + expect( elementShouldBeHidden( element ) ).toBe( false ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/navigable-container/README.md b/gb-src/packages/components/src/navigable-container/README.md new file mode 100644 index 0000000000000..630c9be46f053 --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/README.md @@ -0,0 +1,73 @@ +# NavigableContainers + +`NavigableContainer` is a React component to render a container navigable using the keyboard. Only things that are focusable can be navigated to. It will currently always be a `div`. + +`NavigableContainer` is exported as two classes: `NavigableMenu` and `TabbableContainer`. `NavigableContainer` itself is **not** exported. `NavigableMenu` and `TabbableContainer` have the props listed below. Any other props will be passed through to the `div`. + +---- + +## Props + +These are the props that `NavigableMenu` and `TabbableContainer`. Any props which are specific to one class are labelled appropriately. + +### onNavigate + +A callback invoked when the menu navigates to one of its children passing the index and child as an argument + +- Type: `Function` +- Required: No + +### cycle + +A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. + +- Type: `Boolean` +- Required: No +- default: true + +### orientation (NavigableMenu only) + +The orientation of the menu. It could be "vertical", "horizontal" or "both" + +- Type: `String` +- Required: No +- Default: `"vertical"` + +## Classes + +### NavigableMenu + +A NavigableMenu allows movement up and down (or left and right) the component via the arrow keys. The `tab` key is not handled. The `orientation` prop is used to determine whether the arrow keys used are vertical, horizontal or both. + +### TabbableContainer + +A `TabbableContainer` will only be navigated using the `tab` key. Every intended tabstop must have a tabIndex `0`. + +### Usage + +```jsx +import { NavigableMenu, TabbableContainer, Button } from '@wordpress/components'; + +function onNavigate( index, target ) { + console.log( `Navigates to ${ index }`, target ); +} + +const MyNavigableContainer = () => ( + <div> + <span>Navigable Menu:</span> + <NavigableMenu onNavigate={ onNavigate } orientation="horizontal"> + <Button isDefault>Item 1</Button> + <Button isDefault>Item 2</Button> + <Button isDefault>Item 3</Button> + </NavigableMenu> + + <span>Tabbable Container:</span> + <TabbableContainer onNavigate={ onNavigate }> + <Button isDefault tabIndex="0">Section 1</Button> + <Button isDefault tabIndex="0">Section 2</Button> + <Button isDefault tabIndex="0">Section 3</Button> + <Button isDefault tabIndex="0">Section 4</Button> + </TabbableContainer> + </div> +); +``` diff --git a/gb-src/packages/components/src/navigable-container/container.js b/gb-src/packages/components/src/navigable-container/container.js new file mode 100644 index 0000000000000..a4fb6eeabd80a --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/container.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import { omit, noop, isFunction } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, forwardRef } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; + +function cycleValue( value, total, offset ) { + const nextValue = value + offset; + if ( nextValue < 0 ) { + return total + nextValue; + } else if ( nextValue >= total ) { + return nextValue - total; + } + + return nextValue; +} + +class NavigableContainer extends Component { + constructor() { + super( ...arguments ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + + this.getFocusableContext = this.getFocusableContext.bind( this ); + this.getFocusableIndex = this.getFocusableIndex.bind( this ); + } + + componentDidMount() { + // We use DOM event listeners instead of React event listeners + // because we want to catch events from the underlying DOM tree + // The React Tree can be different from the DOM tree when using + // portals. Block Toolbars for instance are rendered in a separate + // React Trees. + this.container.addEventListener( 'keydown', this.onKeyDown ); + this.container.addEventListener( 'focus', this.onFocus ); + } + + componentWillUnmount() { + this.container.removeEventListener( 'keydown', this.onKeyDown ); + this.container.removeEventListener( 'focus', this.onFocus ); + } + + bindContainer( ref ) { + const { forwardedRef } = this.props; + this.container = ref; + + if ( isFunction( forwardedRef ) ) { + forwardedRef( ref ); + } else if ( forwardedRef && 'current' in forwardedRef ) { + forwardedRef.current = ref; + } + } + + getFocusableContext( target ) { + const { onlyBrowserTabstops } = this.props; + const finder = onlyBrowserTabstops ? focus.tabbable : focus.focusable; + const focusables = finder.find( this.container ); + + const index = this.getFocusableIndex( focusables, target ); + if ( index > -1 && target ) { + return { index, target, focusables }; + } + return null; + } + + getFocusableIndex( focusables, target ) { + const directIndex = focusables.indexOf( target ); + if ( directIndex !== -1 ) { + return directIndex; + } + } + + onKeyDown( event ) { + if ( this.props.onKeyDown ) { + this.props.onKeyDown( event ); + } + + const { getFocusableContext } = this; + const { cycle = true, eventToOffset, onNavigate = noop, stopNavigationEvents } = this.props; + + const offset = eventToOffset( event ); + + // eventToOffset returns undefined if the event is not handled by the component + if ( offset !== undefined && stopNavigationEvents ) { + // Prevents arrow key handlers bound to the document directly interfering + event.stopImmediatePropagation(); + + // When navigating a collection of items, prevent scroll containers + // from scrolling. + if ( event.target.getAttribute( 'role' ) === 'menuitem' ) { + event.preventDefault(); + } + } + + if ( ! offset ) { + return; + } + + const context = getFocusableContext( document.activeElement ); + if ( ! context ) { + return; + } + + const { index, focusables } = context; + const nextIndex = cycle ? cycleValue( index, focusables.length, offset ) : index + offset; + if ( nextIndex >= 0 && nextIndex < focusables.length ) { + focusables[ nextIndex ].focus(); + onNavigate( nextIndex, focusables[ nextIndex ] ); + } + } + + render() { + const { children, ...props } = this.props; + return ( + <div ref={ this.bindContainer } + { ...omit( props, [ + 'stopNavigationEvents', + 'eventToOffset', + 'onNavigate', + 'cycle', + 'onlyBrowserTabstops', + 'forwardedRef', + ] ) } + > + { children } + </div> + ); + } +} + +const forwardedNavigableContainer = ( props, ref ) => { + return <NavigableContainer { ...props } forwardedRef={ ref } />; +}; +forwardedNavigableContainer.displayName = 'NavigableContainer'; + +export default forwardRef( forwardedNavigableContainer ); diff --git a/gb-src/packages/components/src/navigable-container/index.js b/gb-src/packages/components/src/navigable-container/index.js new file mode 100644 index 0000000000000..e98f1b1236d7b --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/index.js @@ -0,0 +1,5 @@ +/** + * Internal Dependencies + */ +export { default as NavigableMenu } from './menu'; +export { default as TabbableContainer } from './tabbable'; diff --git a/gb-src/packages/components/src/navigable-container/menu.js b/gb-src/packages/components/src/navigable-container/menu.js new file mode 100644 index 0000000000000..16411eec386a5 --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/menu.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; +import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; + +export function NavigableMenu( { + role = 'menu', + orientation = 'vertical', + ...rest +}, ref ) { + const eventToOffset = ( evt ) => { + const { keyCode } = evt; + + let next = [ DOWN ]; + let previous = [ UP ]; + + if ( orientation === 'horizontal' ) { + next = [ RIGHT ]; + previous = [ LEFT ]; + } + + if ( orientation === 'both' ) { + next = [ RIGHT, DOWN ]; + previous = [ LEFT, UP ]; + } + + if ( includes( next, keyCode ) ) { + return 1; + } else if ( includes( previous, keyCode ) ) { + return -1; + } + }; + + return ( + <NavigableContainer + ref={ ref } + stopNavigationEvents + onlyBrowserTabstops={ false } + role={ role } + aria-orientation={ role === 'presentation' ? null : orientation } + eventToOffset={ eventToOffset } + { ...rest } + /> + ); +} + +export default forwardRef( NavigableMenu ); diff --git a/gb-src/packages/components/src/navigable-container/tabbable.js b/gb-src/packages/components/src/navigable-container/tabbable.js new file mode 100644 index 0000000000000..bc572522e9c9f --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/tabbable.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; +import { TAB } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; + +export function TabbableContainer( { eventToOffset, ...props }, ref ) { + const innerEventToOffset = ( evt ) => { + const { keyCode, shiftKey } = evt; + if ( TAB === keyCode ) { + return shiftKey ? -1 : 1; + } + + // Allow custom handling of keys besides Tab. + // + // By default, TabbableContainer will move focus forward on Tab and + // backward on Shift+Tab. The handler below will be used for all other + // events. The semantics for `eventToOffset`'s return + // values are the following: + // + // - +1: move focus forward + // - -1: move focus backward + // - 0: don't move focus, but acknowledge event and thus stop it + // - undefined: do nothing, let the event propagate + if ( eventToOffset ) { + return eventToOffset( evt ); + } + }; + + return ( + <NavigableContainer + ref={ ref } + stopNavigationEvents + onlyBrowserTabstops + eventToOffset={ innerEventToOffset } + { ...props } + /> + ); +} + +export default forwardRef( TabbableContainer ); diff --git a/gb-src/packages/components/src/navigable-container/test/menu.js b/gb-src/packages/components/src/navigable-container/test/menu.js new file mode 100644 index 0000000000000..8f19afe270cb6 --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/test/menu.js @@ -0,0 +1,250 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; +import { each } from 'lodash'; + +/** + * WordPress dependencies + */ +import { UP, DOWN, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { NavigableMenu } from '../menu'; + +function simulateVisible( wrapper, selector ) { + const elements = wrapper.getDOMNode().querySelectorAll( selector ); + each( elements, ( elem ) => { + elem.getClientRects = () => [ 'trick-jsdom-into-having-size-for-element-rect' ]; + } ); +} + +function fireKeyDown( container, keyCode, shiftKey ) { + const interaction = { + stopped: false, + }; + + const event = new window.KeyboardEvent( 'keydown', { + keyCode, + shiftKey, + } ); + event.stopImmediatePropagation = () => { + interaction.stopped = true; + }; + container.getDOMNode().dispatchEvent( event ); + + return interaction; +} + +describe( 'NavigableMenu', () => { + it( 'vertical: should navigate by up and down', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <NavigableMenu orientation="vertical" onNavigate={ ( index ) => currentIndex = index }> + <span tabIndex="-1" id="btn1">One</span> + <span tabIndex="-1" id="btn2">Two</span> + <span id="btn-deep-wrapper"> + <span id="btn-deep" tabIndex="-1">Deep</span> + </span> + <span tabIndex="-1" id="btn3">Three</span> + </NavigableMenu> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div' ); + wrapper.getDOMNode().querySelector( '#btn1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, false ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( DOWN, 1, true ); + assertKeyDown( DOWN, 2, true ); + assertKeyDown( DOWN, 3, true ); + assertKeyDown( DOWN, 0, true ); + assertKeyDown( UP, 3, true ); + assertKeyDown( UP, 2, true ); + assertKeyDown( UP, 1, true ); + assertKeyDown( UP, 0, true ); + assertKeyDown( LEFT, 0, false ); + assertKeyDown( RIGHT, 0, false ); + assertKeyDown( SPACE, 0, false ); + } ); + + it( 'vertical: should navigate by up and down, and stop at edges', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <NavigableMenu cycle={ false } orientation="vertical" onNavigate={ ( index ) => currentIndex = index }> + <span tabIndex="-1" id="btn1">One</span> + <span tabIndex="-1" id="btn2">Two</span> + <span tabIndex="-1" id="btn3">Three</span> + </NavigableMenu> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div' ); + wrapper.getDOMNode().querySelector( '#btn1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, false ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( DOWN, 1, true ); + assertKeyDown( DOWN, 2, true ); + assertKeyDown( DOWN, 2, true ); + assertKeyDown( UP, 1, true ); + assertKeyDown( UP, 0, true ); + assertKeyDown( UP, 0, true ); + assertKeyDown( LEFT, 0, false ); + assertKeyDown( RIGHT, 0, false ); + assertKeyDown( SPACE, 0, false ); + } ); + + it( 'horizontal: should navigate by left and right', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <NavigableMenu orientation="horizontal" onNavigate={ ( index ) => currentIndex = index }> + <span tabIndex="-1" id="btn1">One</span> + <span tabIndex="-1" id="btn2">Two</span> + <span id="btn-deep-wrapper"> + <span id="btn-deep" tabIndex="-1">Deep</span> + </span> + <span tabIndex="-1" id="btn3">Three</span> + </NavigableMenu> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div' ); + wrapper.getDOMNode().querySelector( '#btn1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, false ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( RIGHT, 1, true ); + assertKeyDown( RIGHT, 2, true ); + assertKeyDown( RIGHT, 3, true ); + assertKeyDown( RIGHT, 0, true ); + assertKeyDown( LEFT, 3, true ); + assertKeyDown( LEFT, 2, true ); + assertKeyDown( LEFT, 1, true ); + assertKeyDown( LEFT, 0, true ); + assertKeyDown( UP, 0, false ); + assertKeyDown( DOWN, 0, false ); + assertKeyDown( SPACE, 0, false ); + } ); + + it( 'horizontal: should navigate by left and right, and stop at edges', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <NavigableMenu cycle={ false } orientation="horizontal" onNavigate={ ( index ) => currentIndex = index }> + <span tabIndex="-1" id="btn1">One</span> + <span tabIndex="-1" id="btn2">Two</span> + <span tabIndex="-1" id="btn3">Three</span> + </NavigableMenu> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div' ); + wrapper.getDOMNode().querySelector( '#btn1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, false ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( RIGHT, 1, true ); + assertKeyDown( RIGHT, 2, true ); + assertKeyDown( RIGHT, 2, true ); + assertKeyDown( LEFT, 1, true ); + assertKeyDown( LEFT, 0, true ); + assertKeyDown( LEFT, 0, true ); + assertKeyDown( DOWN, 0, false ); + assertKeyDown( UP, 0, false ); + assertKeyDown( SPACE, 0, false ); + } ); + + it( 'both: should navigate by up/down and left/right', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <NavigableMenu orientation="both" onNavigate={ ( index ) => currentIndex = index }> + <button id="btn1">One</button> + <button id="btn2">Two</button> + <button id="btn3">Three</button> + </NavigableMenu> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div' ); + wrapper.getDOMNode().querySelector( '#btn1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( DOWN, 1, true ); + assertKeyDown( DOWN, 2, true ); + assertKeyDown( DOWN, 0, true ); + assertKeyDown( RIGHT, 1, true ); + assertKeyDown( RIGHT, 2, true ); + assertKeyDown( RIGHT, 0, true ); + assertKeyDown( UP, 2, true ); + assertKeyDown( UP, 1, true ); + assertKeyDown( UP, 0, true ); + assertKeyDown( LEFT, 2, true ); + assertKeyDown( LEFT, 1, true ); + assertKeyDown( LEFT, 0, true ); + assertKeyDown( SPACE, 0, false ); + } ); +} ); diff --git a/gb-src/packages/components/src/navigable-container/test/tabbable.js b/gb-src/packages/components/src/navigable-container/test/tabbable.js new file mode 100644 index 0000000000000..65bfbb387c2f3 --- /dev/null +++ b/gb-src/packages/components/src/navigable-container/test/tabbable.js @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; +import { each } from 'lodash'; + +/** + * WordPress dependencies + */ +import { TAB, SPACE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { TabbableContainer } from '../tabbable'; + +function simulateVisible( wrapper, selector ) { + const elements = wrapper.getDOMNode().querySelectorAll( selector ); + each( elements, ( elem ) => { + elem.getClientRects = () => [ 'trick-jsdom-into-having-size-for-element-rect' ]; + } ); +} + +function fireKeyDown( container, keyCode, shiftKey ) { + const interaction = { + stopped: false, + }; + + const event = new window.KeyboardEvent( 'keydown', { + keyCode, + shiftKey, + } ); + event.stopImmediatePropagation = () => { + interaction.stopped = true; + }; + container.getDOMNode().dispatchEvent( event ); + + return interaction; +} + +describe( 'TabbableContainer', () => { + it( 'should navigate by keypresses', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <TabbableContainer className="wrapper" onNavigate={ ( index ) => currentIndex = index }> + <div className="section" id="section1" tabIndex="0">Section One</div> + <div className="section" id="section2" tabIndex="0">Section Two</div> + <div className="deep-section-wrapper"> + <div className="section" id="section-deep" tabIndex="0">Section to <strong>not</strong> skip</div> + </div> + <div className="section" id="section3" tabIndex="0">Section Three</div> + </TabbableContainer> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div.wrapper' ); + wrapper.getDOMNode().querySelector( '#section1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, shiftKey, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, shiftKey ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( TAB, false, 1, true ); + assertKeyDown( TAB, false, 2, true ); + assertKeyDown( TAB, false, 3, true ); + assertKeyDown( TAB, false, 0, true ); + assertKeyDown( TAB, true, 3, true ); + assertKeyDown( TAB, true, 2, true ); + assertKeyDown( TAB, true, 1, true ); + assertKeyDown( TAB, true, 0, true ); + assertKeyDown( SPACE, false, 0, false ); + } ); + + it( 'should navigate by keypresses and stop at edges', () => { + let currentIndex = 0; + const wrapper = mount( ( + /* + Disabled because of our rule restricting literal IDs, preferring + `withInstanceId`. In this case, it's fine to use literal IDs. + */ + /* eslint-disable no-restricted-syntax */ + <TabbableContainer cycle={ false } className="wrapper" onNavigate={ ( index ) => currentIndex = index }> + <div className="section" id="section1" tabIndex="0">Section One</div> + <div className="section" id="section2" tabIndex="0">Section Two</div> + <div className="section" id="section3" tabIndex="0">Section Three</div> + </TabbableContainer> + /* eslint-enable no-restricted-syntax */ + ) ); + + simulateVisible( wrapper, '*' ); + + const container = wrapper.find( 'div.wrapper' ); + wrapper.getDOMNode().querySelector( '#section1' ).focus(); + + // Navigate options + function assertKeyDown( keyCode, shiftKey, expectedActiveIndex, expectedStop ) { + const interaction = fireKeyDown( container, keyCode, shiftKey ); + expect( currentIndex ).toBe( expectedActiveIndex ); + expect( interaction.stopped ).toBe( expectedStop ); + } + + assertKeyDown( TAB, false, 1, true ); + assertKeyDown( TAB, false, 2, true ); + assertKeyDown( TAB, false, 2, true ); + assertKeyDown( TAB, true, 1, true ); + assertKeyDown( TAB, true, 0, true ); + assertKeyDown( TAB, true, 0, true ); + assertKeyDown( SPACE, false, 0, false ); + } ); +} ); diff --git a/gb-src/packages/components/src/notice/README.md b/gb-src/packages/components/src/notice/README.md new file mode 100644 index 0000000000000..16de133c4346a --- /dev/null +++ b/gb-src/packages/components/src/notice/README.md @@ -0,0 +1,109 @@ +# Notice + +Use Notices to communicate prominent messages to the user. + +![Notice component](https://make.wordpress.org/design/files/2019/03/Notice-Screenshot-alt.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +A Notice displays a succinct message. It can also offer the user options, like viewing a published post or updating a setting, and requires a user action to be dismissed. + +Use Notices to communicate things that are important but don’t necessarily require action — a user can keep using the product even if they don’t choose to act on a Notice. They are less interruptive than a Modal. + +### Anatomy + +![Diagram of a Notice component with numbered labels](https://make.wordpress.org/design/files/2019/03/Notice-Anatomy.png) + +1. Container (status indicated with color) +2. Icon (optional) +3. Message +4. Dismiss icon (optional) + +### Usage + +Notices display at the top of the screen, below any toolbars anchored to the top of the page. They’re persistent and non-modal. Since they don’t overlay the content, users can ignore or dismiss them, and choose when to interact with them. + +![](https://make.wordpress.org/design/files/2019/03/Notice-States.png) + +Notices are color-coded to indicate the type of message being communicated: + +- **Default** notices have **no background**. +- **Informational** notices are **blue.** +- **Success** notices are **green.** +- **Warning** notices are **yellow****.** +- **Error** notices are **red.** + +If an icon is included in the Notice, it should be color-coded to match the Notice state. + +![A success Notice for updating a post](https://make.wordpress.org/design/files/2019/03/Notice-Do-1-alt.png) + +**Do** +Do use a Notice when you want to communicate a message of medium importance. + +![A Notice that requires an immediate action](https://make.wordpress.org/design/files/2019/03/Notice-Dont-1-alt.png) + +**Don’t** +Don't use a Notice for a message that requires immediate attention and action from the user. Use a Modal for this instead. + +![A success Notice for publishing a post](https://make.wordpress.org/design/files/2019/03/Notice-Do-2-alt.png) + +**Do** +Do display Notices at the top of the screen, below any toolbars. + +![A success Notice on top of the editor toolbar](https://make.wordpress.org/design/files/2019/03/Notice-Dont-2-alt.png) + +**Don’t** +Don't show Notices on top of toolbars. + +![An error Notice using red](https://make.wordpress.org/design/files/2019/03/Notice-Do-3-alt.png) + +**Do** +Do use color to indicate the type of message being communicated. + +![An error Notice using purple](https://make.wordpress.org/design/files/2019/03/Notice-Dont-3-alt.png) + +**Don’t** +Don't apply any colors other than those for Warnings, Success, or Errors. + +## Development guidelines + +### Usage + +To display a plain notice, pass `Notice` a string: + +```jsx +const MyNotice = () => ( + <Notice status="error"> + An unknown error occurred. + </Notice> +); +``` + +For more complex markup, you can pass any JSX element: + +```jsx +const MyNotice = () => ( + <Notice status="error"> + <p>An error occurred: <code>{ errorDetails }</code>.</p> + </Notice> +); +``` + +#### Props + +The following props are used to control the display of the component. + +* `status`: (string) can be `warning` (yellow), `success` (green), `error` (red). +* `onRemove`: function called when dismissing the notice +* `isDismissible`: (boolean) defaults to true, whether the notice should be dismissible or not +* `actions`: (array) an array of action objects. Each member object should contain a `label` and either a `url` link string or `onClick` callback function. A `className` property can be used to add custom classes to the button styles. By default, some classes are used (e.g: is-link or is-default) the default classes can be removed by setting property `noDefaultClasses` to `false`. + +## Related components + +- To create a more prominent message that requires action, use a Modal. diff --git a/gb-src/packages/components/src/notice/index.js b/gb-src/packages/components/src/notice/index.js new file mode 100644 index 0000000000000..4f55ead17a1a3 --- /dev/null +++ b/gb-src/packages/components/src/notice/index.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Button, IconButton } from '../'; + +function Notice( { + className, + status, + children, + onRemove = noop, + isDismissible = true, + actions = [], + __unstableHTML, +} ) { + const classes = classnames( className, 'components-notice', 'is-' + status, { + 'is-dismissible': isDismissible, + } ); + + if ( __unstableHTML ) { + children = <RawHTML>{ children }</RawHTML>; + } + + return ( + <div className={ classes }> + <div className="components-notice__content"> + { children } + { actions.map( + ( + { + className: buttonCustomClasses, + label, + noDefaultClasses = false, + onClick, + url, + }, + index + ) => { + return ( + <Button + key={ index } + href={ url } + isDefault={ ! noDefaultClasses && ! url } + isLink={ ! noDefaultClasses && !! url } + onClick={ url ? undefined : onClick } + className={ classnames( + 'components-notice__action', + buttonCustomClasses + ) } + > + { label } + </Button> + ); + } + + ) } + </div> + { isDismissible && ( + <IconButton + className="components-notice__dismiss" + icon="no-alt" + label={ __( 'Dismiss this notice' ) } + onClick={ onRemove } + tooltip={ false } + /> + ) } + </div> + ); +} + +export default Notice; diff --git a/gb-src/packages/components/src/notice/list.js b/gb-src/packages/components/src/notice/list.js new file mode 100644 index 0000000000000..64c68cbaede0e --- /dev/null +++ b/gb-src/packages/components/src/notice/list.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { noop, omit } from 'lodash'; + +/** + * Internal dependencies + */ +import Notice from './'; + +/** + * Renders a list of notices. + * + * @param {Object} $0 Props passed to the component. + * @param {Array} $0.notices Array of notices to render. + * @param {Function} $0.onRemove Function called when a notice should be removed / dismissed. + * @param {Object} $0.className Name of the class used by the component. + * @param {Object} $0.children Array of children to be rendered inside the notice list. + * @return {Object} The rendered notices list. + */ +function NoticeList( { notices, onRemove = noop, className, children } ) { + const removeNotice = ( id ) => () => onRemove( id ); + + className = classnames( 'components-notice-list', className ); + + return ( + <div className={ className }> + { children } + { [ ...notices ].reverse().map( ( notice ) => ( + <Notice + { ...omit( notice, [ 'content' ] ) } + key={ notice.id } + onRemove={ removeNotice( notice.id ) } + > + { notice.content } + </Notice> + ) ) } + </div> + ); +} + +export default NoticeList; diff --git a/gb-src/packages/components/src/notice/style.scss b/gb-src/packages/components/src/notice/style.scss new file mode 100644 index 0000000000000..760b9f9fc3be1 --- /dev/null +++ b/gb-src/packages/components/src/notice/style.scss @@ -0,0 +1,83 @@ +.components-notice { + display: flex; + font-family: $default-font; + font-size: $default-font-size; + background-color: $blue-medium-100; + border-left: 4px solid $blue-medium-500; + margin: 5px 15px 2px; + padding: 8px 12px; + align-items: center; + + &.is-dismissible { + padding-right: 36px; + position: relative; + } + + &.is-success { + border-left-color: $alert-green; + background-color: lighten($alert-green, 45%); + } + + &.is-warning { + border-left-color: $alert-yellow; + background-color: lighten($alert-yellow, 35%); + } + + &.is-error { + border-left-color: $alert-red; + background-color: lighten($alert-red, 35%); + } +} + +.components-notice__content { + flex-grow: 1; + margin: $grid-size-small #{ $icon-button-size-small + $border-width } $grid-size-small 0; +} + +.components-notice__action.components-button { + &, + &.is-link { + margin-left: 4px; + } + &.is-default { + vertical-align: initial; + } +} + +.components-notice__dismiss { + color: $dark-gray-300; + + // Place the dismiss button at the top of the container, even when text wraps onto two lines. + align-self: flex-start; + flex-shrink: 0; + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover, + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):active, + &:not(:disabled):not([aria-disabled="true"]):focus { + color: $dark-gray-900; + background-color: transparent; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + } +} + +.components-notice-list { + // The notice should never be wider than the viewport, or the close button might be hidden. Especially relevant at high zoom levels. Related to https://core.trac.wordpress.org/ticket/47603#ticket. + max-width: 100vw; + box-sizing: border-box; + z-index: z-index(".components-notice-list"); + + .components-notice__content { + margin-top: $grid-size + $grid-size-small; + margin-bottom: $grid-size + $grid-size-small; + line-height: 1.6; + } + + // Reduce margins on inline buttons so that they don't add too much extra line-height. + .components-notice__action.components-button { + margin-top: -2px; + margin-bottom: -2px; + } +} diff --git a/gb-src/packages/components/src/notice/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/notice/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..3e9dd6d03fabb --- /dev/null +++ b/gb-src/packages/components/src/notice/test/__snapshots__/index.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Notice should match snapshot 1`] = ` +<div + className="components-notice is-success is-dismissible" +> + <div + className="components-notice__content" + > + Example + <ForwardRef(Button) + className="components-notice__action" + href="https://example.com" + isDefault={false} + isLink={true} + > + View + </ForwardRef(Button)> + </div> + <ForwardRef(IconButton) + className="components-notice__dismiss" + icon="no-alt" + label="Dismiss this notice" + onClick={[Function]} + tooltip={false} + /> +</div> +`; diff --git a/gb-src/packages/components/src/notice/test/index.js b/gb-src/packages/components/src/notice/test/index.js new file mode 100644 index 0000000000000..a4b46887a1ecd --- /dev/null +++ b/gb-src/packages/components/src/notice/test/index.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import ShallowRenderer from 'react-test-renderer/shallow'; + +/** + * WordPress dependencies + */ +import TokenList from '@wordpress/token-list'; + +/** + * Internal dependencies + */ +import Notice from '../index'; + +describe( 'Notice', () => { + it( 'should match snapshot', () => { + const renderer = new ShallowRenderer(); + + renderer.render( + <Notice + status="success" + actions={ [ + { label: 'View', url: 'https://example.com' }, + ] } + > + Example + </Notice> + ); + + expect( renderer.getRenderOutput() ).toMatchSnapshot(); + } ); + + it( 'should not have is-dismissible class when isDismissible prop is false', () => { + const renderer = new ShallowRenderer(); + + renderer.render( <Notice isDismissible={ false } /> ); + + const classes = new TokenList( renderer.getRenderOutput().props.className ); + expect( classes.contains( 'components-notice' ) ).toBe( true ); + expect( classes.contains( 'is-dismissible' ) ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/components/src/notice/test/list.js b/gb-src/packages/components/src/notice/test/list.js new file mode 100644 index 0000000000000..034ff9b7dfd82 --- /dev/null +++ b/gb-src/packages/components/src/notice/test/list.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import ShallowRenderer from 'react-test-renderer/shallow'; + +/** + * WordPress dependencies + */ +import TokenList from '@wordpress/token-list'; + +/** + * Internal dependencies + */ +import NoticeList from '../list'; + +describe( 'NoticeList', () => { + it( 'should merge className', () => { + const renderer = new ShallowRenderer(); + + renderer.render( <NoticeList notices={ [] } className="is-ok" /> ); + + const classes = new TokenList( renderer.getRenderOutput().props.className ); + expect( classes.contains( 'is-ok' ) ).toBe( true ); + expect( classes.contains( 'components-notice-list' ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/components/src/panel/README.md b/gb-src/packages/components/src/panel/README.md new file mode 100644 index 0000000000000..c736ed88b9fa4 --- /dev/null +++ b/gb-src/packages/components/src/panel/README.md @@ -0,0 +1,178 @@ +# Panel + Panels expand and collapse multiple sections of content. + +![](https://make.wordpress.org/design/files/2019/03/panel.png) + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Anatomy + +A Panel is a single section of content that can be expanded or collapsed as needed. + +![Panel anatomy](https://make.wordpress.org/design/files/2019/03/panel-anatomy.png) + +1. Panel +2. Body +3. Divider + +### Usage + +Panels show and hide details of list items by expanding and collapsing list content vertically. Panels help users see only the content they need. + +#### When to use Panels + +Use Panels when it’s helpful to: + +- See an overview of multiple, related sections of content. +- Show and hide those sections as needed. +- Hide information that is lower priority that users don’t need to see all the time. +- View more than one section at a time. + +Consider an alternative component when: + +- There’s crucial information or error messages that require immediate action. +- You need to quickly switch between only a few sections (consider using Tabs instead). + +### Behavior + +#### Expand and collapse + +Show and hide details of existing panel items by expanding and collapsing list content vertically. + +![](https://make.wordpress.org/design/files/2019/03/panel-expand.gif) + +Collapsible panels are indicated with a caret icon that is flipped when expanded. Carets are preferable over a plus or arrow icon, because a plus indicates addition, and arrows are closely related to navigation. + +Panels should be expanded by default if the content is important or essential. Panels that are open by default should appear at the top. + +## Development guidelines + +The `Panel` creates a container with a header that can take collapsible `PanelBody` components to easily create a user friendly interface for affecting state and attributes. + +### Usage + +```jsx +import { Panel, PanelBody, PanelRow } from '@wordpress/components'; + +const MyPanel = () => ( + <Panel header="My Panel"> + <PanelBody + title="My Block Settings" + icon="welcome-widgets-menus" + initialOpen={ true } + > + <PanelRow> + My Panel Inputs and Labels + </PanelRow> + </PanelBody> + </Panel> +); +``` + +### Sub-Components + +#### Panel + +##### Props + +###### className + +The class that will be added with `components-panel`. If no `className` is passed only `components-panel__body` and `is-opened` is used. + +- Type: `String` +- Required: No + +###### header + +Title of the `Panel`. Text will be rendered inside an `<h2>` tag. + +- Type: `String` +- Required: No + +--- +#### PanelBody + +The `PanelBody` creates a collapsible container that can be toggled open or closed. + +##### Props + +###### title + +Title of the `PanelBody`. This shows even when it is closed. + +- Type: `String` +- Required: No + + +###### opened + +If opened is true then the `Panel` will remain open regardless of the `initialOpen` prop and the panel will be prevented from being closed. + +- Type: `Boolean` +- Required: No + +###### className + +The class that will be added with `components-panel__body`, if the panel is currently open, the `is-opened` class will also be passed to the classes of the wrapper div. If no `className` is passed then only `components-panel__body` and `is-opened` is used. + +- Type: `String` +- Required: No + +###### icon + +An icon to be shown next to the `PanelBody` title. + +- Type: `String` +- Required: No + +###### onToggle + +A function that is called when the user clicks on the `PanelBody` title after the open state is changed. + +- Type: `function` +- Required: No + +###### initialOpen + +Whether or not the panel will start open. + +- Type: `Boolean` +- Required: No +- Default: true + +--- +#### PanelRow + +The is a generic container for panel content. Default styles add a top margin and arrange items in a flex row. + +##### Props + +###### className + +The class that will be added with `components-panel__row`. to the classes of the wrapper div. If no `className` is passed only `components-panel__row` is used. + +- Type: `String` +- Required: No +--- + +#### PanelHeader + +This is a simple container for a header component. This is used by the `Panel` component under the hood, so it does not typically need to be used. + +##### Props + +###### label + +The text that will be rendered as the title of the `Panel`. Will be rendered in an `<h2>` tag. + +- Type: `String` +- Required: No + +## Related components +- To divide related sections of content accessed by a horizontal menu, use `TabPanel` diff --git a/gb-src/packages/components/src/panel/body.js b/gb-src/packages/components/src/panel/body.js new file mode 100644 index 0000000000000..b7b60e976e44e --- /dev/null +++ b/gb-src/packages/components/src/panel/body.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component, forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import Icon from '../icon'; +import { G, Path, SVG } from '../primitives'; + +export class PanelBody extends Component { + constructor( props ) { + super( ...arguments ); + this.state = { + opened: props.initialOpen === undefined ? true : props.initialOpen, + }; + this.toggle = this.toggle.bind( this ); + } + + toggle( event ) { + event.preventDefault(); + if ( this.props.opened === undefined ) { + this.setState( ( state ) => ( { + opened: ! state.opened, + } ) ); + } + + if ( this.props.onToggle ) { + this.props.onToggle(); + } + } + + render() { + const { title, children, opened, className, icon, forwardedRef } = this.props; + const isOpened = opened === undefined ? this.state.opened : opened; + const classes = classnames( 'components-panel__body', className, { 'is-opened': isOpened } ); + + return ( + <div className={ classes } ref={ forwardedRef }> + { !! title && ( + <h2 className="components-panel__body-title"> + <Button + className="components-panel__body-toggle" + onClick={ this.toggle } + aria-expanded={ isOpened } + > + { /* + Firefox + NVDA don't announce aria-expanded because the browser + repaints the whole element, so this wrapping span hides that. + */ } + <span aria-hidden="true"> + { isOpened ? + <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> + <G><Path d="M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z" /></G> + </SVG> : + <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> + <G><Path d="M7.41,8.59L12,13.17l4.59-4.58L18,10l-6,6l-6-6L7.41,8.59z" /></G> + </SVG> + } + </span> + { title } + { icon && <Icon icon={ icon } className="components-panel__icon" size={ 20 } /> } + </Button> + </h2> + ) } + { isOpened && children } + </div> + ); + } +} + +const forwardedPanelBody = ( props, ref ) => { + return <PanelBody { ...props } forwardedRef={ ref } />; +}; +forwardedPanelBody.displayName = 'PanelBody'; + +export default forwardRef( forwardedPanelBody ); diff --git a/gb-src/packages/components/src/panel/header.js b/gb-src/packages/components/src/panel/header.js new file mode 100644 index 0000000000000..d26812e8b6289 --- /dev/null +++ b/gb-src/packages/components/src/panel/header.js @@ -0,0 +1,10 @@ +function PanelHeader( { label, children } ) { + return ( + <div className="components-panel__header"> + { label && <h2>{ label }</h2> } + { children } + </div> + ); +} + +export default PanelHeader; diff --git a/gb-src/packages/components/src/panel/index.js b/gb-src/packages/components/src/panel/index.js new file mode 100644 index 0000000000000..96e9daf8e647b --- /dev/null +++ b/gb-src/packages/components/src/panel/index.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import PanelHeader from './header'; + +function Panel( { header, className, children } ) { + const classNames = classnames( className, 'components-panel' ); + return ( + <div className={ classNames }> + { header && <PanelHeader label={ header } /> } + { children } + </div> + ); +} + +export default Panel; diff --git a/gb-src/packages/components/src/panel/row.js b/gb-src/packages/components/src/panel/row.js new file mode 100644 index 0000000000000..ca9e9e4854dbf --- /dev/null +++ b/gb-src/packages/components/src/panel/row.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +function PanelRow( { className, children } ) { + const classes = classnames( 'components-panel__row', className ); + + return ( + <div className={ classes }> + { children } + </div> + ); +} + +export default PanelRow; diff --git a/gb-src/packages/components/src/panel/style.scss b/gb-src/packages/components/src/panel/style.scss new file mode 100644 index 0000000000000..828af037a724f --- /dev/null +++ b/gb-src/packages/components/src/panel/style.scss @@ -0,0 +1,157 @@ +.components-panel { + background: $white; + border: $border-width solid $light-gray-500; + + > .components-panel__header:first-child, + > .components-panel__body:first-child { + margin-top: -1px; + } + + > .components-panel__header:last-child, + > .components-panel__body:last-child { + border-bottom-width: 0; + } +} + +.components-panel + .components-panel { + margin-top: -1px; +} + +.components-panel__body { + border-top: $border-width solid $light-gray-500; + border-bottom: $border-width solid $light-gray-500; + + h3 { + margin: 0 0 0.5em; + } + + &.is-opened { + padding: $panel-padding; + } + + > .components-icon-button { + color: $dark-gray-900; + } +} + +.components-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 $panel-padding; + height: 50px; + border-top: $border-width solid $light-gray-500; + border-bottom: $border-width solid $light-gray-500; + + h2 { + margin: 0; + font-size: inherit; + color: inherit; + } +} + +.components-panel__body + .components-panel__body, +.components-panel__body + .components-panel__header, +.components-panel__header + .components-panel__body, +.components-panel__header + .components-panel__header { + margin-top: -1px; +} + +.components-panel__body > .components-panel__body-title { + display: block; + padding: 0; + font-size: inherit; + margin-top: 0; + margin-bottom: 0; + transition: 0.1s background ease-in-out; + @include reduce-motion("transition"); +} +.components-panel__body.is-opened > .components-panel__body-title { + margin: -1 * $panel-padding; + margin-bottom: 5px; +} + +// Hover States +.components-panel__body > .components-panel__body-title:hover, +.edit-post-last-revision__panel > .components-icon-button:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + background: $light-gray-200; +} + +.components-panel__body-toggle.components-button { + position: relative; + padding: 15px; + outline: none; + width: 100%; + font-weight: 600; + text-align: left; + color: $dark-gray-900; + @include menu-style__neutral; + transition: 0.1s background ease-in-out; + @include reduce-motion("transition"); + + &:focus:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__focus; + } + + .components-panel__arrow { + position: absolute; + right: $grid-size + 2; + top: 50%; + transform: translateY(-50%); + color: $dark-gray-900; + fill: currentColor; + transition: 0.1s color ease-in-out; + @include reduce-motion("transition"); + } + + // mirror the arrow horizontally in RTL languages + /* rtl:begin:ignore */ + body.rtl & .dashicons-arrow-right { + transform: scaleX(-1); + -ms-filter: fliph; + filter: FlipH; + margin-top: -10px; + } + /* rtl:end:ignore */ +} + +.components-panel__icon { + color: $dark-gray-500; + margin: -2px 0 -2px 6px; +} + +.components-panel__body-toggle-icon { + margin-right: -5px; +} + +.components-panel__color-title { + float: left; + height: 19px; +} + +.components-panel__row { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + + select { + // Necessary for select to respond to flexbox + min-width: 0; + } + + label { + margin-right: 10px; + flex-shrink: 0; + max-width: 75%; + } + + &:empty, + &:first-of-type { + margin-top: 0; + } +} + +.components-panel .circle-picker { + padding-bottom: 20px; +} diff --git a/gb-src/packages/components/src/panel/test/body.js b/gb-src/packages/components/src/panel/test/body.js new file mode 100644 index 0000000000000..78d4774afccd8 --- /dev/null +++ b/gb-src/packages/components/src/panel/test/body.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { shallow, mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { PanelBody } from '../body'; + +describe( 'PanelBody', () => { + describe( 'basic rendering', () => { + it( 'should render an empty div with the matching className', () => { + const panelBody = shallow( <PanelBody /> ); + expect( panelBody.hasClass( 'components-panel__body' ) ).toBe( true ); + expect( panelBody.type() ).toBe( 'div' ); + } ); + + it( 'should render an Button matching the following props and state', () => { + const panelBody = shallow( <PanelBody title="Some Text" /> ); + const button = panelBody.find( '.components-panel__body-toggle' ); + expect( panelBody.hasClass( 'is-opened' ) ).toBe( true ); + expect( panelBody.state( 'opened' ) ).toBe( true ); + expect( button.prop( 'onClick' ) ).toBe( panelBody.instance().toggle ); + expect( button.childAt( 0 ).name() ).toBe( 'span' ); + expect( button.childAt( 0 ).childAt( 0 ).name() ).toBe( 'SVG' ); + expect( button.childAt( 1 ).text() ).toBe( 'Some Text' ); + } ); + + it( 'should change state and class when sidebar is closed', () => { + const panelBody = shallow( <PanelBody title="Some Text" initialOpen={ false } /> ); + expect( panelBody.state( 'opened' ) ).toBe( false ); + expect( panelBody.hasClass( 'is-opened' ) ).toBe( false ); + } ); + + it( 'should use the "opened" prop instead of state if provided', () => { + const panelBody = shallow( <PanelBody title="Some Text" opened={ true } initialOpen={ false } /> ); + expect( panelBody.state( 'opened' ) ).toBe( false ); + expect( panelBody.hasClass( 'is-opened' ) ).toBe( true ); + } ); + + it( 'should render child elements within PanelBody element', () => { + const panelBody = shallow( <PanelBody children="Some Text" /> ); + expect( panelBody.instance().props.children ).toBe( 'Some Text' ); + expect( panelBody.text() ).toBe( 'Some Text' ); + } ); + + it( 'should pass children prop but not render when sidebar is closed', () => { + const panelBody = shallow( <PanelBody children="Some Text" initialOpen={ false } /> ); + expect( panelBody.instance().props.children ).toBe( 'Some Text' ); + // Text should be empty even though props.children is set. + expect( panelBody.text() ).toBe( '' ); + } ); + } ); + + describe( 'mounting behavior', () => { + it( 'should mount with a default of being opened', () => { + const panelBody = mount( <PanelBody /> ); + expect( panelBody.state( 'opened' ) ).toBe( true ); + } ); + + it( 'should mount with a state of not opened when initialOpen set to false', () => { + const panelBody = mount( <PanelBody initialOpen={ false } /> ); + expect( panelBody.state( 'opened' ) ).toBe( false ); + } ); + } ); + + describe( 'toggling behavior', () => { + const fakeEvent = { preventDefault: () => undefined }; + + it( 'should set the opened state to false when a toggle fires', () => { + const panelBody = mount( <PanelBody /> ); + panelBody.instance().toggle( fakeEvent ); + expect( panelBody.state( 'opened' ) ).toBe( false ); + } ); + + it( 'should set the opened state to true when a toggle fires on a closed state', () => { + const panelBody = mount( <PanelBody initialOpen={ false } /> ); + panelBody.instance().toggle( fakeEvent ); + expect( panelBody.state( 'opened' ) ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/panel/test/header.js b/gb-src/packages/components/src/panel/test/header.js new file mode 100644 index 0000000000000..1955042e5c4ff --- /dev/null +++ b/gb-src/packages/components/src/panel/test/header.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import PanelHeader from '../header.js'; + +describe( 'PanelHeader', () => { + describe( 'basic rendering', () => { + it( 'should render PanelHeader with empty div inside', () => { + const panelHeader = shallow( <PanelHeader /> ); + expect( panelHeader.hasClass( 'components-panel__header' ) ).toBe( true ); + expect( panelHeader.type() ).toBe( 'div' ); + expect( panelHeader.find( 'div' ).shallow().children() ).toHaveLength( 0 ); + } ); + + it( 'should render a label matching the text provided in the prop', () => { + const panelHeader = shallow( <PanelHeader label="Some Text" /> ); + const label = panelHeader.find( 'h2' ).shallow(); + expect( label.text() ).toBe( 'Some Text' ); + expect( label.type() ).toBe( 'h2' ); + } ); + + it( 'should render child elements in the panel header body when provided', () => { + const panelHeader = shallow( <PanelHeader children="Some Text" /> ); + expect( panelHeader.text() ).toBe( 'Some Text' ); + expect( panelHeader.find( 'div' ).shallow().children() ).toHaveLength( 1 ); + } ); + + it( 'should render both child elements and label when passed in', () => { + const panelHeader = shallow( <PanelHeader label="Some Label" children="Some Text" /> ); + expect( panelHeader.find( 'div' ).shallow().children() ).toHaveLength( 2 ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/panel/test/index.js b/gb-src/packages/components/src/panel/test/index.js new file mode 100644 index 0000000000000..18641ded21a07 --- /dev/null +++ b/gb-src/packages/components/src/panel/test/index.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Panel from '../'; + +describe( 'Panel', () => { + describe( 'basic rendering', () => { + it( 'should render an empty div without any provided props', () => { + const panel = shallow( <Panel /> ); + expect( panel.hasClass( 'components-panel' ) ).toBe( true ); + expect( panel.type() ).toBe( 'div' ); + expect( panel.find( 'div' ).shallow().children() ).toHaveLength( 0 ); + } ); + + it( 'should render a PanelHeader component when provided text in the header prop', () => { + const panel = shallow( <Panel header="Header Label" /> ); + const panelHeader = panel.find( 'PanelHeader' ); + expect( panelHeader.prop( 'label' ) ).toBe( 'Header Label' ); + expect( panel.find( 'div' ).shallow().children() ).toHaveLength( 1 ); + } ); + + it( 'should render an additional className', () => { + const panel = shallow( <Panel className="the-panel" /> ); + expect( panel.hasClass( 'the-panel' ) ).toBe( true ); + } ); + + it( 'should add additional child elements to be rendered in the panel', () => { + const panel = shallow( <Panel children="The Panel" /> ); + expect( panel.text() ).toBe( 'The Panel' ); + expect( panel.find( 'div' ).shallow().children() ).toHaveLength( 1 ); + } ); + + it( 'should render both children and header when provided as props', () => { + const panel = shallow( <Panel children="The Panel" header="The Header" /> ); + expect( panel.find( 'div' ).shallow().children() ).toHaveLength( 2 ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/panel/test/row.js b/gb-src/packages/components/src/panel/test/row.js new file mode 100644 index 0000000000000..c59591b00ad88 --- /dev/null +++ b/gb-src/packages/components/src/panel/test/row.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import PanelRow from '../row'; + +describe( 'PanelRow', () => { + it( 'should defined default class name', () => { + const wrapper = shallow( <PanelRow /> ); + expect( wrapper.hasClass( 'components-panel__row' ) ).toBe( true ); + } ); + it( 'should defined custom class name', () => { + const wrapper = shallow( <PanelRow className="custom" /> ); + expect( wrapper.hasClass( 'custom' ) ).toBe( true ); + } ); + it( 'should return child components', () => { + const wrapper = shallow( <PanelRow><p>children</p></PanelRow> ); + expect( wrapper.find( 'p' ).text() ).toBe( 'children' ); + } ); +} ); diff --git a/gb-src/packages/components/src/placeholder/README.md b/gb-src/packages/components/src/placeholder/README.md new file mode 100644 index 0000000000000..2a12363e7fde0 --- /dev/null +++ b/gb-src/packages/components/src/placeholder/README.md @@ -0,0 +1,22 @@ +# Placeholder + +## Usage +```jsx +import { Placeholder } from '@wordpress/components'; + +const MyPlaceholder = () => ( + <Placeholder + icon="wordpress-alt" + label="Placeholder" + /> +); +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`icon` | `string, ReactElement` | `undefined` | If provided, renders an icon next to the label. +`label` | `string` | `undefined` | Renders a label for the placeholder. +`instructions` | `string` | `undefined` | Renders instruction text below label. +`isColumnLayout` | `bool` | `false` | Changes placeholder children layout from flex-row to flex-column. diff --git a/gb-src/packages/components/src/placeholder/index.js b/gb-src/packages/components/src/placeholder/index.js new file mode 100644 index 0000000000000..3a7c40c993001 --- /dev/null +++ b/gb-src/packages/components/src/placeholder/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isString } from 'lodash'; + +/** + * Internal dependencies + */ +import Dashicon from '../dashicon'; + +/** + * Renders a placeholder. Normally used by blocks to render their empty state. + * + * @param {Object} props The component props. + * @return {Object} The rendered placeholder. + */ +function Placeholder( { icon, children, label, instructions, className, notices, preview, isColumnLayout, ...additionalProps } ) { + const classes = classnames( 'components-placeholder', className ); + const fieldsetClasses = classnames( 'components-placeholder__fieldset', { 'is-column-layout': isColumnLayout } ); + return ( + <div { ...additionalProps } className={ classes }> + { notices } + { preview && + <div className="components-placeholder__preview"> + { preview } + </div> + } + <div className="components-placeholder__label"> + { isString( icon ) ? <Dashicon icon={ icon } /> : icon } + { label } + </div> + { !! instructions && <div className="components-placeholder__instructions">{ instructions }</div> } + <div className={ fieldsetClasses }> + { children } + </div> + </div> + ); +} + +export default Placeholder; diff --git a/gb-src/packages/components/src/placeholder/style.scss b/gb-src/packages/components/src/placeholder/style.scss new file mode 100644 index 0000000000000..31be38e0e6ea4 --- /dev/null +++ b/gb-src/packages/components/src/placeholder/style.scss @@ -0,0 +1,79 @@ +.components-placeholder { + margin-bottom: $default-block-margin; + padding: 1em; + min-height: 200px; + width: 100%; + text-align: center; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + // Use opacity to work in various editor styles. + background: $dark-opacity-light-200; + + .is-dark-theme & { + background: $light-opacity-light-200; + } +} + +.components-placeholder__instructions, +.components-placeholder__label, +.components-placeholder__fieldset { + font-family: $default-font; + font-size: $default-font-size; +} + +.components-placeholder__label { + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + margin-bottom: 1em; + + .dashicon, + .block-editor-block-icon { + fill: currentColor; + margin-right: 1ch; + } +} + +.components-placeholder__fieldset, +.components-placeholder__fieldset form { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + max-width: 400px; + flex-wrap: wrap; + z-index: z-index(".components-placeholder__fieldset"); + + p { + font-family: $default-font; + font-size: $default-font-size; + } +} + +.components-placeholder__fieldset.is-column-layout, +.components-placeholder__fieldset.is-column-layout form { + flex-direction: column; +} + +.components-placeholder__input { + margin-top: 0; + margin-right: 8px; + flex: 1 1 auto; +} + +.components-placeholder__instructions { + margin-bottom: 1em; +} + +.components-placeholder__preview img { + margin: 3%; + width: 50%; +} diff --git a/gb-src/packages/components/src/placeholder/test/index.js b/gb-src/packages/components/src/placeholder/test/index.js new file mode 100644 index 0000000000000..54f9f847f9750 --- /dev/null +++ b/gb-src/packages/components/src/placeholder/test/index.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Placeholder from '../'; + +describe( 'Placeholder', () => { + describe( 'basic rendering', () => { + it( 'should by default render label section and fieldset.', () => { + const placeholder = shallow( <Placeholder /> ); + const placeholderLabel = placeholder.find( '.components-placeholder__label' ); + const placeholderInstructions = placeholder.find( '.components-placeholder__instructions' ); + const placeholderFieldset = placeholder.find( '.components-placeholder__fieldset' ); + + expect( placeholder.hasClass( 'components-placeholder' ) ).toBe( true ); + // Test for empty label. + expect( placeholderLabel.exists() ).toBe( true ); + expect( placeholderLabel.find( 'Dashicon' ).exists() ).toBe( false ); + // Test for non existant instructions. + expect( placeholderInstructions.exists() ).toBe( false ); + // Test for empty fieldset. + expect( placeholderFieldset.exists() ).toBe( true ); + } ); + + it( 'should render a Dashicon in the label section', () => { + const placeholder = shallow( <Placeholder icon="wordpress" /> ); + const placeholderLabel = placeholder.find( '.components-placeholder__label' ); + + expect( placeholderLabel.exists() ).toBe( true ); + expect( placeholderLabel.find( 'Dashicon' ).exists() ).toBe( true ); + } ); + + it( 'should render a label section', () => { + const label = 'WordPress'; + const placeholder = shallow( <Placeholder label={ label } /> ); + const placeholderLabel = placeholder.find( '.components-placeholder__label' ); + const child = placeholderLabel.childAt( 0 ); + + expect( child.text() ).toBe( label ); + } ); + + it( 'should display an instructions element', () => { + const element = <div>Instructions</div>; + const placeholder = shallow( <Placeholder instructions={ element } /> ); + const placeholderInstructions = placeholder.find( '.components-placeholder__instructions' ); + const child = placeholderInstructions.childAt( 0 ); + + expect( placeholderInstructions.exists() ).toBe( true ); + expect( child.matchesElement( element ) ).toBe( true ); + } ); + + it( 'should display a fieldset from the children property', () => { + const element = <div>Fieldset</div>; + const placeholder = shallow( <Placeholder children={ element } /> ); + const placeholderFieldset = placeholder.find( '.components-placeholder__fieldset' ); + const child = placeholderFieldset.childAt( 0 ); + + expect( placeholderFieldset.exists() ).toBe( true ); + expect( child.matchesElement( element ) ).toBe( true ); + } ); + + it( 'should add an additional className to the top container', () => { + const placeholder = shallow( <Placeholder className="wp-placeholder" /> ); + expect( placeholder.hasClass( 'wp-placeholder' ) ).toBe( true ); + } ); + + it( 'should add additional props to the top level container', () => { + const placeholder = shallow( <Placeholder test="test" /> ); + expect( placeholder.prop( 'test' ) ).toBe( 'test' ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/popover/README.md b/gb-src/packages/components/src/popover/README.md new file mode 100644 index 0000000000000..c587720596933 --- /dev/null +++ b/gb-src/packages/components/src/popover/README.md @@ -0,0 +1,139 @@ +Popover +======= + +Popover is a React component to render a floating content modal. It is similar in purpose to a tooltip, but renders content of any sort, not only simple text. It anchors itself to its parent node, optionally by a specified direction. If the popover exceeds the bounds of the page in the direction it opens, its position will be flipped automatically. + +## Usage + +Render a Popover within the parent to which it should anchor: + +```jsx +import { Button, Popover } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyPopover = withState( { + isVisible: false, +} )( ( { isVisible, setState } ) => { + const toggleVisible = () => { + setState( ( state ) => ( { isVisible: ! state.isVisible } ) ); + }; + return ( + <Button isDefault onClick={ toggleVisible }> + Toggle Popover! + { isVisible && ( + <Popover> + Popover is toggled! + </Popover> + ) } + </Button> + ); +} ); +``` + +If a Popover is returned by your component, it will be shown. To hide the popover, simply omit it from your component's render value. + +If you want Popover elements to render to a specific location on the page to allow style cascade to take effect, you must render a `Popover.Slot` further up the element tree: + +```jsx +import { render } from '@wordpress/element'; +import { Popover } from '@wordpress/components'; +import Content from './Content'; + +const app = document.getElementById( 'app' ); + +render( + <div> + <Content /> + <Popover.Slot /> + </div>, + app +); +``` + +## Props + +The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content. + +### focusOnMount + +By default, the *first tabblable element* in the popover will receive focus when it mounts. This is the same as setting `focusOnMount` to `"firstElement"`. If you want to focus the container instead, you can set `focusOnMount` to `"container"`. + +Set this prop to `false` to disable focus changing entirely. This should only be set when an appropriately accessible substitute behavior exists. + +- Type: `String` or `Boolean` +- Required: No +- Default: `"firstElement"` + +### position + +The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"middle"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis. + +- Type: `String` +- Required: No +- Default: `"top center"` + +### children + +The content to be displayed within the popover. + +- Type: `Element` +- Required: Yes + +### className + +An optional additional class name to apply to the rendered popover. + +- Type: `String` +- Required: No + +### onClose + +A callback invoked when the popover should be closed. + +- Type: `Function` +- Required: No + +### onFocusOutside + +A callback invoked when the focus leaves the opened popover. This should only be provided in advanced use-cases when a Popover should close under specific circumstances; for example, if the new `document.activeElement` is content of or otherwise controlling Popover visibility. + +Defaults to `onClose` when not provided. + +- Type: `Function` +- Required: No + +### expandOnMobile + +Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to avoid this behavior. + + - Type: `Boolean` + - Required: No + - Default: `false` + + ### headerTitle + + Set this to customize the text that is shown in popover's header when it is fullscreen on mobile. + + - Type: `String` + - Required: No + + ### noArrow + + Set this to hide the arrow which visually indicates what the popover is anchored to. Note that the arrow will not display if `position` is set to `"middle center"`. + + - Type: `Boolean` + - Required: No + - Default: `false` + +### anchorRect + +A custom `DOMRect` object at which to position the popover. + +- Type: `DOMRect` +- Required: No + +## Methods + +### refresh + +Calling `refresh()` will force the Popover to recalculate its size and position. This is useful when a DOM change causes the anchor node to change position. diff --git a/gb-src/packages/components/src/popover/detect-outside.js b/gb-src/packages/components/src/popover/detect-outside.js new file mode 100644 index 0000000000000..92a3359d24055 --- /dev/null +++ b/gb-src/packages/components/src/popover/detect-outside.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withFocusOutside from '../higher-order/with-focus-outside'; + +class PopoverDetectOutside extends Component { + handleFocusOutside( event ) { + this.props.onFocusOutside( event ); + } + + render() { + return this.props.children; + } +} + +export default withFocusOutside( PopoverDetectOutside ); diff --git a/gb-src/packages/components/src/popover/index.js b/gb-src/packages/components/src/popover/index.js new file mode 100644 index 0000000000000..420ac78b76bdb --- /dev/null +++ b/gb-src/packages/components/src/popover/index.js @@ -0,0 +1,455 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useRef, useState, useEffect } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; +import { ESCAPE } from '@wordpress/keycodes'; +import isShallowEqual from '@wordpress/is-shallow-equal'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import { computePopoverPosition } from './utils'; +import withFocusReturn from '../higher-order/with-focus-return'; +import withConstrainedTabbing from '../higher-order/with-constrained-tabbing'; +import PopoverDetectOutside from './detect-outside'; +import IconButton from '../icon-button'; +import ScrollLock from '../scroll-lock'; +import IsolatedEventContainer from '../isolated-event-container'; +import { Slot, Fill, Consumer } from '../slot-fill'; +import Animate from '../animate'; + +const FocusManaged = withConstrainedTabbing( withFocusReturn( ( { children } ) => children ) ); + +/** + * Name of slot in which popover should fill. + * + * @type {string} + */ +const SLOT_NAME = 'Popover'; + +/** + * Hook used trigger an event handler once the window is resized or scrolled. + * + * @param {Function} handler Event handler. + * @param {Object} ignoredScrollableRef scroll events inside this element are ignored. + */ +function useThrottledWindowScrollOrResize( handler, ignoredScrollableRef ) { + // Refresh anchor rect on resize + useEffect( () => { + let refreshHandle; + const throttledRefresh = ( event ) => { + window.cancelAnimationFrame( refreshHandle ); + if ( ignoredScrollableRef && event && event.type === 'scroll' && ignoredScrollableRef.current.contains( event.target ) ) { + return; + } + refreshHandle = window.requestAnimationFrame( handler ); + }; + + window.addEventListener( 'resize', throttledRefresh ); + window.addEventListener( 'scroll', throttledRefresh, true ); + + return () => { + window.removeEventListener( 'resize', throttledRefresh ); + window.removeEventListener( 'scroll', throttledRefresh, true ); + }; + }, [] ); +} + +/** + * Hook used to compute and update the anchor position properly. + * + * @param {Object} anchorRef reference to the popover anchor element. + * @param {Object} contentRef reference to the popover content element. + * @param {Object} anchorRect anchor Rect prop used to override the computed value. + * @param {Function} getAnchorRect function used to override the anchor value computation algorithm. + * + * @return {Object} Anchor position. + */ +function useAnchor( anchorRef, contentRef, anchorRect, getAnchorRect ) { + const [ anchor, setAnchor ] = useState( null ); + const refreshAnchorRect = () => { + if ( ! anchorRef.current ) { + return; + } + + let newAnchor; + if ( anchorRect ) { + newAnchor = anchorRect; + } else if ( getAnchorRect ) { + newAnchor = getAnchorRect( anchorRef.current ); + } else { + const rect = anchorRef.current.parentNode.getBoundingClientRect(); + // subtract padding + const { paddingTop, paddingBottom } = window.getComputedStyle( anchorRef.current.parentNode ); + const topPad = parseInt( paddingTop, 10 ); + const bottomPad = parseInt( paddingBottom, 10 ); + newAnchor = { + x: rect.left, + y: rect.top + topPad, + width: rect.width, + height: rect.height - topPad - bottomPad, + left: rect.left, + right: rect.right, + top: rect.top + topPad, + bottom: rect.bottom - bottomPad, + }; + } + + const didAnchorRectChange = ! isShallowEqual( newAnchor, anchor ); + if ( didAnchorRectChange ) { + setAnchor( newAnchor ); + } + }; + useEffect( refreshAnchorRect, [ anchorRect, getAnchorRect ] ); + useEffect( () => { + if ( ! anchorRect ) { + /* + * There are sometimes we need to reposition or resize the popover that are not + * handled by the resize/scroll window events (i.e. CSS changes in the layout + * that changes the position of the anchor). + * + * For these situations, we refresh the popover every 0.5s + */ + const intervalHandle = setInterval( refreshAnchorRect, 500 ); + + return () => clearInterval( intervalHandle ); + } + }, [ anchorRect ] ); + + useThrottledWindowScrollOrResize( refreshAnchorRect, contentRef ); + + return anchor; +} + +/** + * Hook used to compute the initial size of an element. + * The popover applies styling to limit the height of the element, + * we only care about the initial size. + * + * @param {Object} ref Reference to the popover content element. + * + * @return {Object} Content size. + */ +function useInitialContentSize( ref ) { + const [ contentSize, setContentSize ] = useState( null ); + useEffect( () => { + const contentRect = ref.current.getBoundingClientRect(); + setContentSize( { + width: contentRect.width, + height: contentRect.height, + } ); + }, [] ); + + return contentSize; +} + +/** + * Hook used to compute and update the position of the popover + * based on the anchor position and the content size. + * + * @param {Object} anchor Anchor Position. + * @param {Object} contentSize Content Size. + * @param {string} position Position prop. + * @param {boolean} expandOnMobile Whether to show the popover full width on mobile. + * @param {Object} contentRef Reference to the popover content element. + * + * @return {Object} Popover position. + */ +function usePopoverPosition( anchor, contentSize, position, expandOnMobile, contentRef ) { + const [ popoverPosition, setPopoverPosition ] = useState( { + popoverLeft: null, + popoverTop: null, + yAxis: 'top', + xAxis: 'center', + contentHeight: null, + contentWidth: null, + isMobile: false, + } ); + const refreshPopoverPosition = () => { + if ( ! anchor || ! contentSize ) { + return; + } + + const newPopoverPosition = computePopoverPosition( + anchor, + contentSize, + position, + expandOnMobile + ); + + if ( + popoverPosition.yAxis !== newPopoverPosition.yAxis || + popoverPosition.xAxis !== newPopoverPosition.xAxis || + popoverPosition.popoverLeft !== newPopoverPosition.popoverLeft || + popoverPosition.popoverTop !== newPopoverPosition.popoverTop || + popoverPosition.contentHeight !== newPopoverPosition.contentHeight || + popoverPosition.contentWidth !== newPopoverPosition.contentWidth || + popoverPosition.isMobile !== newPopoverPosition.isMobile + ) { + setPopoverPosition( newPopoverPosition ); + } + }; + useEffect( refreshPopoverPosition, [ anchor, contentSize ] ); + useThrottledWindowScrollOrResize( refreshPopoverPosition, contentRef ); + + return popoverPosition; +} + +/** + * Hook used to focus the first tabbable element on mount. + * + * @param {boolean|string} focusOnMount Focus on mount mode. + * @param {Object} contentRef Reference to the popover content element. + */ +function useFocusContentOnMount( focusOnMount, contentRef ) { + // Focus handling + useEffect( () => { + /* + * Without the setTimeout, the dom node is not being focused. Related: + * https://stackoverflow.com/questions/35522220/react-ref-with-focus-doesnt-work-without-settimeout-my-example + * + * TODO: Treat the cause, not the symptom. + */ + const focusTimeout = setTimeout( () => { + if ( ! focusOnMount || ! contentRef.current ) { + return; + } + + if ( focusOnMount === 'firstElement' ) { + // Find first tabbable node within content and shift focus, falling + // back to the popover panel itself. + const firstTabbable = focus.tabbable.find( contentRef.current )[ 0 ]; + if ( firstTabbable ) { + firstTabbable.focus(); + } else { + contentRef.current.focus(); + } + + return; + } + + if ( focusOnMount === 'container' ) { + // Focus the popover panel itself so items in the popover are easily + // accessed via keyboard navigation. + contentRef.current.focus(); + } + }, 0 ); + + return () => clearTimeout( focusTimeout ); + }, [] ); +} + +const Popover = ( { + headerTitle, + onClose, + onKeyDown, + children, + className, + noArrow = false, + // Disable reason: We generate the `...contentProps` rest as remainder + // of props which aren't explicitly handled by this component. + /* eslint-disable no-unused-vars */ + position = 'top', + range, + focusOnMount = 'firstElement', + anchorRect, + getAnchorRect, + expandOnMobile, + animate = true, + onClickOutside, + onFocusOutside, + /* eslint-enable no-unused-vars */ + ...contentProps +} ) => { + const anchorRef = useRef( null ); + const contentRef = useRef( null ); + + // Animation + const [ isReadyToAnimate, setIsReadyToAnimate ] = useState( false ); + + // Anchor position + const anchor = useAnchor( anchorRef, contentRef, anchorRect, getAnchorRect ); + + // Content size + const contentSize = useInitialContentSize( contentRef ); + useEffect( () => { + if ( contentSize ) { + setIsReadyToAnimate( true ); + } + }, [ contentSize ] ); + + // Compute the position + const popoverPosition = usePopoverPosition( + anchor, + contentSize, + position, + expandOnMobile, + contentRef + ); + + useFocusContentOnMount( focusOnMount, contentRef ); + + // Event handlers + const maybeClose = ( event ) => { + // Close on escape + if ( event.keyCode === ESCAPE && onClose ) { + event.stopPropagation(); + onClose(); + } + + // Preserve original content prop behavior + if ( onKeyDown ) { + onKeyDown( event ); + } + }; + + /** + * Shims an onFocusOutside callback to be compatible with a deprecated + * onClickOutside prop function, if provided. + * + * @param {FocusEvent} event Focus event from onFocusOutside. + */ + function handleOnFocusOutside( event ) { + // Defer to given `onFocusOutside` if specified. Call `onClose` only if + // both `onFocusOutside` and `onClickOutside` are unspecified. Doing so + // assures backwards-compatibility for prior `onClickOutside` default. + if ( onFocusOutside ) { + onFocusOutside( event ); + return; + } else if ( ! onClickOutside ) { + if ( onClose ) { + onClose(); + } + return; + } + + // Simulate MouseEvent using FocusEvent#relatedTarget as emulated click + // target. MouseEvent constructor is unsupported in Internet Explorer. + let clickEvent; + try { + clickEvent = new window.MouseEvent( 'click' ); + } catch ( error ) { + clickEvent = document.createEvent( 'MouseEvent' ); + clickEvent.initMouseEvent( 'click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null ); + } + + Object.defineProperty( clickEvent, 'target', { + get: () => event.relatedTarget, + } ); + + deprecated( 'Popover onClickOutside prop', { + alternative: 'onFocusOutside', + } ); + + onClickOutside( clickEvent ); + } + + // Compute the animation position + const yAxisMapping = { + top: 'bottom', + bottom: 'top', + }; + const xAxisMapping = { + left: 'right', + right: 'left', + }; + const animateYAxis = yAxisMapping[ popoverPosition.yAxis ] || 'middle'; + const animateXAxis = xAxisMapping[ popoverPosition.xAxis ] || 'center'; + + const classes = classnames( + 'components-popover', + className, + 'is-' + popoverPosition.yAxis, + 'is-' + popoverPosition.xAxis, + { + 'is-mobile': popoverPosition.isMobile, + 'is-without-arrow': noArrow || ( + popoverPosition.xAxis === 'center' && + popoverPosition.yAxis === 'middle' + ), + } + ); + + // Disable reason: We care to capture the _bubbled_ events from inputs + // within popover as inferring close intent. + + let content = ( + <PopoverDetectOutside onFocusOutside={ handleOnFocusOutside }> + <Animate + type={ animate && isReadyToAnimate ? 'appear' : null } + options={ { origin: animateYAxis + ' ' + animateXAxis } } + > + { ( { className: animateClassName } ) => ( + <IsolatedEventContainer + className={ classnames( classes, animateClassName ) } + style={ { + top: ! popoverPosition.isMobile && popoverPosition.popoverTop ? popoverPosition.popoverTop + 'px' : undefined, + left: ! popoverPosition.isMobile && popoverPosition.popoverLeft ? popoverPosition.popoverLeft + 'px' : undefined, + visibility: contentSize ? undefined : 'hidden', + } } + { ...contentProps } + onKeyDown={ maybeClose } + > + { popoverPosition.isMobile && ( + <div className="components-popover__header"> + <span className="components-popover__header-title"> + { headerTitle } + </span> + <IconButton className="components-popover__close" icon="no-alt" onClick={ onClose } /> + </div> + ) } + <div + ref={ contentRef } + className="components-popover__content" + style={ { + maxHeight: ! popoverPosition.isMobile && popoverPosition.contentHeight ? popoverPosition.contentHeight + 'px' : undefined, + maxWidth: ! popoverPosition.isMobile && popoverPosition.contentWidth ? popoverPosition.contentWidth + 'px' : undefined, + } } + tabIndex="-1" + > + { children } + </div> + </IsolatedEventContainer> + ) } + </Animate> + </PopoverDetectOutside> + ); + + // Apply focus to element as long as focusOnMount is truthy; false is + // the only "disabled" value. + if ( focusOnMount ) { + content = <FocusManaged>{ content }</FocusManaged>; + } + + return ( + <Consumer> + { ( { getSlot } ) => { + // In case there is no slot context in which to render, + // default to an in-place rendering. + if ( getSlot && getSlot( SLOT_NAME ) ) { + content = <Fill name={ SLOT_NAME }>{ content }</Fill>; + } + + return ( + <span ref={ anchorRef }> + { content } + { popoverPosition.isMobile && expandOnMobile && <ScrollLock /> } + </span> + ); + } } + </Consumer> + ); +}; + +const PopoverContainer = Popover; + +PopoverContainer.Slot = () => <Slot bubblesVirtually name={ SLOT_NAME } />; + +export default PopoverContainer; diff --git a/gb-src/packages/components/src/popover/style.scss b/gb-src/packages/components/src/popover/style.scss new file mode 100644 index 0000000000000..4312f89fe84f9 --- /dev/null +++ b/gb-src/packages/components/src/popover/style.scss @@ -0,0 +1,206 @@ +$arrow-size: 8px; + +/*!rtl:begin:ignore*/ +.components-popover { + position: fixed; + z-index: z-index(".components-popover"); + left: 50%; + + &.is-mobile { + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + &:not(.is-without-arrow):not(.is-mobile) { + margin-left: 2px; + + &::before { + border: $arrow-size solid $light-gray-500; + } + + &::after { + border: $arrow-size solid $white; + } + + &::before, + &::after { + content: ""; + position: absolute; + height: 0; + width: 0; + line-height: 0; + } + + &.is-top { + margin-top: - $arrow-size; + + &::before { + bottom: - $arrow-size; + } + + &::after { + bottom: -6px; + } + + &::before, + &::after { + border-bottom: none; + border-left-color: transparent; + border-right-color: transparent; + border-top-style: solid; + margin-left: -10px; + } + } + + &.is-bottom { + margin-top: $arrow-size; + + &::before { + top: -$arrow-size; + } + + &::after { + top: -6px; + } + + &::before, + &::after { + border-bottom-style: solid; + border-left-color: transparent; + border-right-color: transparent; + border-top: none; + margin-left: -10px; + } + } + + &.is-middle.is-left { + margin-left: -$arrow-size; + + &::before { + right: -$arrow-size; + } + + &::after { + right: -6px; + } + + &::before, + &::after { + border-bottom-color: transparent; + border-left-style: solid; + border-right: none; + border-top-color: transparent; + } + } + + &.is-middle.is-right { + margin-left: $arrow-size; + + &::before { + left: -$arrow-size; + } + + &::after { + left: -6px; + } + + &::before, + &::after { + border-bottom-color: transparent; + border-left: none; + border-right-style: solid; + border-top-color: transparent; + } + } + } + + &:not(.is-mobile) { + &.is-top { + bottom: 100%; + } + + &.is-bottom { + top: 100%; + } + + &.is-middle { + align-items: center; + display: flex; + } + } +} + +.components-popover__content { + box-shadow: $shadow-popover; + border: $border-width solid $light-gray-500; + background: $white; + height: 100%; + + .components-popover.is-mobile & { + height: calc(100% - #{ $panel-header-height }); + border-top: 0; + } + + .components-popover:not(.is-mobile) & { + position: absolute; + height: auto; + overflow-y: auto; + min-width: 260px; + } + + .components-popover:not(.is-mobile).is-top & { + bottom: 100%; + } + + .components-popover:not(.is-mobile).is-center & { + left: 50%; + transform: translateX(-50%); + } + + .components-popover:not(.is-mobile).is-right & { + position: absolute; + left: 100%; + } + + .components-popover:not(.is-mobile):not(.is-middle).is-right & { + margin-left: -24px; + } + + .components-popover:not(.is-mobile).is-left & { + position: absolute; + right: 100%; + } + + .components-popover:not(.is-mobile):not(.is-middle).is-left & { + margin-right: -24px; + } +} + +// The withFocusReturn div +.components-popover__content > div { + height: 100%; +} + +.components-popover__header { + align-items: center; + background: $white; + border: $border-width solid $light-gray-500; + display: flex; + height: $panel-header-height; + justify-content: space-between; + padding: 0 8px 0 $panel-padding; +} + +.components-popover__header-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.components-popover__close.components-icon-button { + z-index: z-index(".components-popover__close"); +} +/*!rtl:end:ignore*/ diff --git a/gb-src/packages/components/src/popover/test/__snapshots__/index.js.snap b/gb-src/packages/components/src/popover/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..614f2dd55e5ee --- /dev/null +++ b/gb-src/packages/components/src/popover/test/__snapshots__/index.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Popover should pass additional props to portaled element 1`] = ` +<span> + <div + tabindex="-1" + > + <div> + <div> + <div + class="components-popover is-bottom is-center components-animate__appear is-from-top" + role="tooltip" + style="" + > + <div + class="components-popover__content" + tabindex="-1" + > + Hello + </div> + </div> + </div> + </div> + </div> +</span> +`; + +exports[`Popover should render content 1`] = ` +<span> + <div + tabindex="-1" + > + <div> + <div> + <div + class="components-popover is-bottom is-center components-animate__appear is-from-top" + style="" + > + <div + class="components-popover__content" + tabindex="-1" + > + Hello + </div> + </div> + </div> + </div> + </div> +</span> +`; diff --git a/gb-src/packages/components/src/popover/test/index.js b/gb-src/packages/components/src/popover/test/index.js new file mode 100644 index 0000000000000..1ae2b3e932fc5 --- /dev/null +++ b/gb-src/packages/components/src/popover/test/index.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import TestUtils from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Popover from '../'; + +jest.useFakeTimers(); + +class PopoverWrapper extends Component { + render() { + return <Popover { ...this.props } />; + } +} + +describe( 'Popover', () => { + afterEach( () => { + if ( document.activeElement ) { + document.activeElement.blur(); + } + } ); + + it( 'should focus when opening in response to keyboard event', () => { + // As in the real world, these occur in sequence before the popover + // has been mounted. Keyup's resetting is deferred. + document.dispatchEvent( new window.KeyboardEvent( 'keydown' ) ); + document.dispatchEvent( new window.KeyboardEvent( 'keyup' ) ); + + // An ideal test here would mount with an input child and focus the + // child, but in context of JSDOM the inputs are not visible and + // are therefore skipped as tabbable, defaulting to popover. + let wrapper; + TestUtils.act( () => { + wrapper = TestUtils.renderIntoDocument( <PopoverWrapper /> ); + + jest.advanceTimersByTime( 1 ); + + const content = TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-popover__content' + ); + expect( document.activeElement ).toBe( content ); + } ); + } ); + + it( 'should allow focus-on-open behavior to be disabled', () => { + const activeElement = document.activeElement; + TestUtils.act( () => { + TestUtils.renderIntoDocument( <Popover focusOnMount={ false } /> ); + + jest.advanceTimersByTime( 1 ); + + expect( document.activeElement ).toBe( activeElement ); + } ); + } ); + + it( 'should render content', () => { + let wrapper; + TestUtils.act( () => { + wrapper = TestUtils.renderIntoDocument( <PopoverWrapper>Hello</PopoverWrapper> ); + } ); + const content = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'span' ); + + expect( content ).toMatchSnapshot(); + } ); + + it( 'should pass additional props to portaled element', () => { + let wrapper; + TestUtils.act( () => { + wrapper = TestUtils.renderIntoDocument( <PopoverWrapper role="tooltip">Hello</PopoverWrapper> ); + } ); + const content = TestUtils.findRenderedDOMComponentWithTag( wrapper, 'span' ); + + expect( content ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/components/src/popover/test/utils.js b/gb-src/packages/components/src/popover/test/utils.js new file mode 100644 index 0000000000000..3c491fa98d9a5 --- /dev/null +++ b/gb-src/packages/components/src/popover/test/utils.js @@ -0,0 +1,216 @@ +/** + * Internal dependencies + */ +import { + computePopoverPosition, + computePopoverYAxisPosition, + computePopoverXAxisPosition, +} from '../utils'; + +describe( 'computePopoverYAxisPosition', () => { + it( 'should leave the position as is there’s enought space', () => { + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverYAxisPosition( anchorRect, contentSize, 'bottom' ) ).toEqual( { + contentHeight: null, + popoverTop: 30, + yAxis: 'bottom', + } ); + } ); + + it( "should switch to bottom position if there's not enough space", () => { + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverYAxisPosition( anchorRect, contentSize, 'top' ) ).toEqual( { + contentHeight: null, + popoverTop: 30, + yAxis: 'bottom', + } ); + } ); + + it( "should set a maxHeight if there's not enough space in any direction", () => { + const anchorRect = { + top: 400, + left: 10, + bottom: 420, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 500, + }; + + expect( computePopoverYAxisPosition( anchorRect, contentSize, 'bottom' ) ).toEqual( { + contentHeight: 390, + popoverTop: 400, + yAxis: 'top', + } ); + } ); + + it( 'should position a popover in the middle', () => { + const anchorRect = { + top: 400, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverYAxisPosition( anchorRect, contentSize, 'middle' ) ).toEqual( { + contentHeight: null, + popoverTop: 410, + yAxis: 'middle', + } ); + } ); +} ); + +describe( 'computePopoverXAxisPosition', () => { + it( 'should leave the position as is there’s enought space', () => { + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverXAxisPosition( anchorRect, contentSize, 'right' ) ).toEqual( { + contentWidth: null, + popoverLeft: 20, + xAxis: 'right', + } ); + } ); + + it( "should switch to right position if there's not enough space", () => { + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverXAxisPosition( anchorRect, contentSize, 'center' ) ).toEqual( { + contentWidth: null, + popoverLeft: 20, + xAxis: 'right', + } ); + } ); + + it( "should set a maxWidth if there's not enough space in any direction", () => { + const anchorRect = { + top: 10, + left: 400, + bottom: 30, + right: 420, + width: 20, + height: 20, + }; + + const contentSize = { + width: 800, + height: 300, + }; + + expect( computePopoverXAxisPosition( anchorRect, contentSize, 'right' ) ).toEqual( { + contentWidth: 614, + popoverLeft: 410, + xAxis: 'right', + } ); + } ); +} ); + +describe( 'computePopoverPosition', () => { + it( 'should leave the position as is there’s enought space', () => { + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + expect( computePopoverPosition( anchorRect, contentSize, 'bottom right' ) ).toEqual( { + contentWidth: null, + popoverLeft: 20, + xAxis: 'right', + contentHeight: null, + popoverTop: 30, + yAxis: 'bottom', + isMobile: false, + } ); + } ); + + it( 'should set isMobile to true on small viewPosts', () => { + const originalWidth = window.innerWidth; + + const anchorRect = { + top: 10, + left: 10, + bottom: 30, + right: 30, + width: 20, + height: 20, + }; + + const contentSize = { + width: 200, + height: 300, + }; + + window.innerWidth = 200; + expect( computePopoverPosition( anchorRect, contentSize, 'bottom right', true ).isMobile ).toBe( true ); + window.innerWidth = originalWidth; + } ); +} ); diff --git a/gb-src/packages/components/src/popover/utils.js b/gb-src/packages/components/src/popover/utils.js new file mode 100644 index 0000000000000..1b71fab09aa57 --- /dev/null +++ b/gb-src/packages/components/src/popover/utils.js @@ -0,0 +1,162 @@ + +/** + * Module constants + */ +const HEIGHT_OFFSET = 10; // used by the arrow and a bit of empty space +const isMobileViewport = () => window.innerWidth < 782; +const isRTL = () => document.documentElement.dir === 'rtl'; + +/** + * Utility used to compute the popover position over the xAxis + * + * @param {Object} anchorRect Anchor Rect. + * @param {Object} contentSize Content Size. + * @param {string} xAxis Desired xAxis. + * @param {string} chosenYAxis yAxis to be used. + * + * @return {Object} Popover xAxis position and constraints. + */ +export function computePopoverXAxisPosition( anchorRect, contentSize, xAxis, chosenYAxis ) { + const { width } = contentSize; + // Correct xAxis for RTL support + if ( xAxis === 'left' && isRTL() ) { + xAxis = 'right'; + } else if ( xAxis === 'right' && isRTL() ) { + xAxis = 'left'; + } + + // x axis alignment choices + const anchorMidPoint = Math.round( anchorRect.left + ( anchorRect.width / 2 ) ); + const centerAlignment = { + popoverLeft: anchorMidPoint, + contentWidth: ( + ( anchorMidPoint - ( width / 2 ) > 0 ? ( width / 2 ) : anchorMidPoint ) + + ( anchorMidPoint + ( width / 2 ) > window.innerWidth ? window.innerWidth - anchorMidPoint : ( width / 2 ) ) + ), + }; + const leftAlignmentX = chosenYAxis === 'middle' ? anchorRect.left : anchorMidPoint; + const leftAlignment = { + popoverLeft: leftAlignmentX, + contentWidth: leftAlignmentX - width > 0 ? width : leftAlignmentX, + }; + const rightAlignmentX = chosenYAxis === 'middle' ? anchorRect.right : anchorMidPoint; + const rightAlignment = { + popoverLeft: rightAlignmentX, + contentWidth: rightAlignmentX + width > window.innerWidth ? window.innerWidth - rightAlignmentX : width, + }; + + // Choosing the x axis + let chosenXAxis; + let contentWidth = null; + if ( xAxis === 'center' && centerAlignment.contentWidth === width ) { + chosenXAxis = 'center'; + } else if ( xAxis === 'left' && leftAlignment.contentWidth === width ) { + chosenXAxis = 'left'; + } else if ( xAxis === 'right' && rightAlignment.contentWidth === width ) { + chosenXAxis = 'right'; + } else { + chosenXAxis = leftAlignment.contentWidth > rightAlignment.contentWidth ? 'left' : 'right'; + const chosenWidth = chosenXAxis === 'left' ? leftAlignment.contentWidth : rightAlignment.contentWidth; + contentWidth = chosenWidth !== width ? chosenWidth : null; + } + + let popoverLeft; + if ( chosenXAxis === 'center' ) { + popoverLeft = centerAlignment.popoverLeft; + } else if ( chosenXAxis === 'left' ) { + popoverLeft = leftAlignment.popoverLeft; + } else { + popoverLeft = rightAlignment.popoverLeft; + } + + return { + xAxis: chosenXAxis, + popoverLeft, + contentWidth, + }; +} + +/** + * Utility used to compute the popover position over the yAxis + * + * @param {Object} anchorRect Anchor Rect. + * @param {Object} contentSize Content Size. + * @param {string} yAxis Desired yAxis. + * + * @return {Object} Popover xAxis position and constraints. + */ +export function computePopoverYAxisPosition( anchorRect, contentSize, yAxis ) { + const { height } = contentSize; + + // y axis alignment choices + const anchorMidPoint = anchorRect.top + ( anchorRect.height / 2 ); + const middleAlignment = { + popoverTop: anchorMidPoint, + contentHeight: ( + ( anchorMidPoint - ( height / 2 ) > 0 ? ( height / 2 ) : anchorMidPoint ) + + ( anchorMidPoint + ( height / 2 ) > window.innerHeight ? window.innerHeight - anchorMidPoint : ( height / 2 ) ) + ), + }; + const topAlignment = { + popoverTop: anchorRect.top, + contentHeight: anchorRect.top - HEIGHT_OFFSET - height > 0 ? height : anchorRect.top - HEIGHT_OFFSET, + }; + const bottomAlignment = { + popoverTop: anchorRect.bottom, + contentHeight: anchorRect.bottom + HEIGHT_OFFSET + height > window.innerHeight ? window.innerHeight - HEIGHT_OFFSET - anchorRect.bottom : height, + }; + + // Choosing the y axis + let chosenYAxis; + let contentHeight = null; + if ( yAxis === 'middle' && middleAlignment.contentHeight === height ) { + chosenYAxis = 'middle'; + } else if ( yAxis === 'top' && topAlignment.contentHeight === height ) { + chosenYAxis = 'top'; + } else if ( yAxis === 'bottom' && bottomAlignment.contentHeight === height ) { + chosenYAxis = 'bottom'; + } else { + chosenYAxis = topAlignment.contentHeight > bottomAlignment.contentHeight ? 'top' : 'bottom'; + const chosenHeight = chosenYAxis === 'top' ? topAlignment.contentHeight : bottomAlignment.contentHeight; + contentHeight = chosenHeight !== height ? chosenHeight : null; + } + + let popoverTop; + if ( chosenYAxis === 'middle' ) { + popoverTop = middleAlignment.popoverTop; + } else if ( chosenYAxis === 'top' ) { + popoverTop = topAlignment.popoverTop; + } else { + popoverTop = bottomAlignment.popoverTop; + } + + return { + yAxis: chosenYAxis, + popoverTop, + contentHeight, + }; +} + +/** + * Utility used to compute the popover position and the content max width/height for a popover + * given its anchor rect and its content size. + * + * @param {Object} anchorRect Anchor Rect. + * @param {Object} contentSize Content Size. + * @param {string} position Position. + * @param {boolean} expandOnMobile Whether to expand the popover on mobile or not. + * + * @return {Object} Popover position and constraints. + */ +export function computePopoverPosition( anchorRect, contentSize, position = 'top', expandOnMobile = false ) { + const [ yAxis, xAxis = 'center' ] = position.split( ' ' ); + + const yAxisPosition = computePopoverYAxisPosition( anchorRect, contentSize, yAxis ); + const xAxisPosition = computePopoverXAxisPosition( anchorRect, contentSize, xAxis, yAxisPosition.yAxis ); + + return { + isMobile: isMobileViewport() && expandOnMobile, + ...xAxisPosition, + ...yAxisPosition, + }; +} diff --git a/gb-src/packages/components/src/primitives/block-quotation/README.md b/gb-src/packages/components/src/primitives/block-quotation/README.md new file mode 100644 index 0000000000000..83d9f9057609e --- /dev/null +++ b/gb-src/packages/components/src/primitives/block-quotation/README.md @@ -0,0 +1,15 @@ +# HorizontalRule + +A drop-in replacement for the HTML [blockquote](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/blockquote) element that works both on the web and in the mobile apps. It indicates that the enclosed text is an extended quotation. + +## Usage + +```jsx +import { BlockQuotation } from '@wordpress/components'; + +const MyBlockQuotation = () => ( + <BlockQuotation> + ...Quote content + </BlockQuotation> +); +``` diff --git a/gb-src/packages/components/src/primitives/block-quotation/index.js b/gb-src/packages/components/src/primitives/block-quotation/index.js new file mode 100644 index 0000000000000..f1ccb4968c84e --- /dev/null +++ b/gb-src/packages/components/src/primitives/block-quotation/index.js @@ -0,0 +1 @@ +export const BlockQuotation = 'blockquote'; diff --git a/gb-src/packages/components/src/primitives/block-quotation/index.native.js b/gb-src/packages/components/src/primitives/block-quotation/index.native.js new file mode 100644 index 0000000000000..abbafa09cae94 --- /dev/null +++ b/gb-src/packages/components/src/primitives/block-quotation/index.native.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; +/** + * WordPress dependencies + */ +import { Children, cloneElement } from '@wordpress/element'; +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export const BlockQuotation = ( props ) => { + const newChildren = Children.map( props.children, ( child ) => { + if ( child && child.props.identifier === 'citation' ) { + return cloneElement( child, { style: styles.wpBlockQuoteCitation } ); + } + if ( child && child.props.identifier === 'value' ) { + return cloneElement( child, { tagsToEliminate: [ 'div' ] } ); + } + return child; + } ); + return ( + <View style={ styles.wpBlockQuote } > + { newChildren } + </View> + ); +}; diff --git a/gb-src/packages/components/src/primitives/block-quotation/style.native.scss b/gb-src/packages/components/src/primitives/block-quotation/style.native.scss new file mode 100644 index 0000000000000..2e88c68cf26fa --- /dev/null +++ b/gb-src/packages/components/src/primitives/block-quotation/style.native.scss @@ -0,0 +1,12 @@ +.wpBlockQuote { + border-left-width: 4px; + border-left-color: $black; + border-left-style: solid; + padding-left: 8px; + margin-left: 0; +} + +.wpBlockQuoteCitation { + margin-top: 16px; + font-size: 14px; +} diff --git a/gb-src/packages/components/src/primitives/horizontal-rule/README.md b/gb-src/packages/components/src/primitives/horizontal-rule/README.md new file mode 100644 index 0000000000000..f568024bacd7c --- /dev/null +++ b/gb-src/packages/components/src/primitives/horizontal-rule/README.md @@ -0,0 +1,13 @@ +# HorizontalRule + +A drop-in replacement for the HTML [hr](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/hr) element that works both on the web and in the mobile apps. It represents a thematic break between paragraph-level elements: for example, a change of scene in a story, or a shift of topic within a section. + +## Usage + +```jsx +import { HorizontalRule } from '@wordpress/components'; + +const MyHorizontalRule = () => ( + <HorizontalRule /> +); +``` diff --git a/gb-src/packages/components/src/primitives/horizontal-rule/index.js b/gb-src/packages/components/src/primitives/horizontal-rule/index.js new file mode 100644 index 0000000000000..6a3853bba9970 --- /dev/null +++ b/gb-src/packages/components/src/primitives/horizontal-rule/index.js @@ -0,0 +1 @@ +export const HorizontalRule = 'hr'; diff --git a/gb-src/packages/components/src/primitives/horizontal-rule/index.native.js b/gb-src/packages/components/src/primitives/horizontal-rule/index.native.js new file mode 100644 index 0000000000000..7c5382762c4c5 --- /dev/null +++ b/gb-src/packages/components/src/primitives/horizontal-rule/index.native.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import Hr from 'react-native-hr'; + +export const HorizontalRule = ( props ) => { + const lineStyle = { + backgroundColor: '#555d66', + height: 2, + ...props.lineStyle, + }; + + return ( + <Hr { ...props } lineStyle={ lineStyle } /> + ); +}; diff --git a/gb-src/packages/components/src/primitives/index.js b/gb-src/packages/components/src/primitives/index.js new file mode 100644 index 0000000000000..ba5d2f6f2ab08 --- /dev/null +++ b/gb-src/packages/components/src/primitives/index.js @@ -0,0 +1,3 @@ +export * from './svg'; +export * from './horizontal-rule'; +export * from './block-quotation'; diff --git a/gb-src/packages/components/src/primitives/svg/README.md b/gb-src/packages/components/src/primitives/svg/README.md new file mode 100644 index 0000000000000..ee204cce51e5d --- /dev/null +++ b/gb-src/packages/components/src/primitives/svg/README.md @@ -0,0 +1,23 @@ +# SVG + +A drop-in replacement for the svg element that adds the required accessibility attributes for SVG elements across browsers. + +## Usage + +```jsx +import { G, Path, SVG } from '@wordpress/components'; + +const MyIcon = () => ( + <SVG + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <Path fill="none" d="M0 0h24v24H0V0z" /> + <G> + <Path d="M20 4v12H8V4h12m0-2H8L6 4v12l2 2h12l2-2V4l-2-2z" /> + <Path d="M12 12l1 2 3-3 3 4H9z" /> + <Path d="M2 6v14l2 2h14v-2H4V6H2z" /> + </G> + </SVG> +); +``` diff --git a/gb-src/packages/components/src/primitives/svg/index.js b/gb-src/packages/components/src/primitives/svg/index.js new file mode 100644 index 0000000000000..abb15e121dc2b --- /dev/null +++ b/gb-src/packages/components/src/primitives/svg/index.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; + +export const Circle = ( props ) => createElement( 'circle', props ); +export const G = ( props ) => createElement( 'g', props ); +export const Path = ( props ) => createElement( 'path', props ); +export const Polygon = ( props ) => createElement( 'polygon', props ); +export const Rect = ( props ) => createElement( 'rect', props ); + +export const SVG = ( props ) => { + const appliedProps = { + ...props, + role: 'img', + 'aria-hidden': 'true', + focusable: 'false', + }; + + // Disable reason: We need to have a way to render HTML tag for web. + // eslint-disable-next-line react/forbid-elements + return <svg { ...appliedProps } />; +}; diff --git a/gb-src/packages/components/src/primitives/svg/index.native.js b/gb-src/packages/components/src/primitives/svg/index.native.js new file mode 100644 index 0000000000000..b0272e6b5a7b9 --- /dev/null +++ b/gb-src/packages/components/src/primitives/svg/index.native.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { Svg } from 'react-native-svg'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export { + Circle, + G, + Path, + Polygon, + Rect, +} from 'react-native-svg'; + +export const SVG = ( props ) => { + const stylesFromClasses = ( props.className || '' ).split( ' ' ).map( ( element ) => styles[ element ] ).filter( Boolean ); + const styleValues = Object.assign( {}, props.style, ...stylesFromClasses ); + const safeProps = { ...props, style: styleValues }; + + return ( + <Svg + //We want to re-render when style color is changed + key={ safeProps.style.color } + height="100%" + width="100%" + { ...safeProps } + /> + ); +}; + diff --git a/gb-src/packages/components/src/primitives/svg/style.native.scss b/gb-src/packages/components/src/primitives/svg/style.native.scss new file mode 100644 index 0000000000000..e5a64ea140d0d --- /dev/null +++ b/gb-src/packages/components/src/primitives/svg/style.native.scss @@ -0,0 +1,24 @@ +.dashicon { + color: #7b9ab1; + fill: currentColor; +} + +.dashicon-active { + color: #fff; + fill: currentColor; +} + +.dashicons-insert { + color: #87a6bc; + fill: currentColor; +} + +.unsupported-icon { + color: $gray-dark; + fill: currentColor; +} + +.warning-icon { + color: $gray-dark; + fill: currentColor; +} diff --git a/gb-src/packages/components/src/query-controls/README.md b/gb-src/packages/components/src/query-controls/README.md new file mode 100644 index 0000000000000..83dd4092e18b6 --- /dev/null +++ b/gb-src/packages/components/src/query-controls/README.md @@ -0,0 +1,42 @@ +# QueryControls + +## Usage + +```jsx +import { QueryControls } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyQueryControls = withState( { + orderBy: 'title', + order: 'asc', + category: 1, + categories: [ + { + id: 1, + name: 'Category 1', + parent: 0, + }, + { + id: 2, + name: 'Category 1b', + parent: 1, + }, + { + id: 3, + name: 'Category 2', + parent: 0, + }, + ], + numberOfItems: 10, +} )( ( { orderBy, order, category, categories, numberOfItems, setState } ) => ( + <QueryControls + { ...{ orderBy, order, numberOfItems } } + onOrderByChange={ ( orderBy ) => setState( { orderBy } ) } + onOrderChange={ ( order ) => setState( { order } ) } + categoriesList={ categories } + selectedCategoryId={ category } + onCategoryChange={ ( category ) => setState( { category } ) } + onNumberOfItemsChange={ ( numberOfItems ) => setState( { numberOfItems } ) } + /> +) ); +``` diff --git a/gb-src/packages/components/src/query-controls/category-select.js b/gb-src/packages/components/src/query-controls/category-select.js new file mode 100644 index 0000000000000..0ee0575addaf5 --- /dev/null +++ b/gb-src/packages/components/src/query-controls/category-select.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { buildTermsTree } from './terms'; +import TreeSelect from '../tree-select'; + +export default function CategorySelect( { label, noOptionLabel, categoriesList, selectedCategoryId, onChange } ) { + const termsTree = buildTermsTree( categoriesList ); + return ( + <TreeSelect + { ...{ label, noOptionLabel, onChange } } + tree={ termsTree } + selectedId={ selectedCategoryId } + /> + ); +} diff --git a/gb-src/packages/components/src/query-controls/index.js b/gb-src/packages/components/src/query-controls/index.js new file mode 100644 index 0000000000000..f3cbf47998325 --- /dev/null +++ b/gb-src/packages/components/src/query-controls/index.js @@ -0,0 +1,86 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { RangeControl, SelectControl } from '../'; +import CategorySelect from './category-select'; + +const DEFAULT_MIN_ITEMS = 1; +const DEFAULT_MAX_ITEMS = 100; + +export default function QueryControls( { + categoriesList, + selectedCategoryId, + numberOfItems, + order, + orderBy, + maxItems = DEFAULT_MAX_ITEMS, + minItems = DEFAULT_MIN_ITEMS, + onCategoryChange, + onNumberOfItemsChange, + onOrderChange, + onOrderByChange, +} ) { + return [ + ( onOrderChange && onOrderByChange ) && ( + <SelectControl + key="query-controls-order-select" + label={ __( 'Order by' ) } + value={ `${ orderBy }/${ order }` } + options={ [ + { + label: __( 'Newest to Oldest' ), + value: 'date/desc', + }, + { + label: __( 'Oldest to Newest' ), + value: 'date/asc', + }, + { + /* translators: label for ordering posts by title in ascending order */ + label: __( 'A → Z' ), + value: 'title/asc', + }, + { + /* translators: label for ordering posts by title in descending order */ + label: __( 'Z → A' ), + value: 'title/desc', + }, + ] } + onChange={ ( value ) => { + const [ newOrderBy, newOrder ] = value.split( '/' ); + if ( newOrder !== order ) { + onOrderChange( newOrder ); + } + if ( newOrderBy !== orderBy ) { + onOrderByChange( newOrderBy ); + } + } } + /> + ), + onCategoryChange && ( + <CategorySelect + key="query-controls-category-select" + categoriesList={ categoriesList } + label={ __( 'Category' ) } + noOptionLabel={ __( 'All' ) } + selectedCategoryId={ selectedCategoryId } + onChange={ onCategoryChange } + /> ), + onNumberOfItemsChange && ( + <RangeControl + key="query-controls-range-control" + label={ __( 'Number of items' ) } + value={ numberOfItems } + onChange={ onNumberOfItemsChange } + min={ minItems } + max={ maxItems } + required + /> + ), + ]; +} diff --git a/gb-src/packages/components/src/query-controls/terms.js b/gb-src/packages/components/src/query-controls/terms.js new file mode 100644 index 0000000000000..ba76e68980184 --- /dev/null +++ b/gb-src/packages/components/src/query-controls/terms.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { groupBy } from 'lodash'; + +/** + * Returns terms in a tree form. + * + * @param {Array} flatTerms Array of terms in flat format. + * + * @return {Array} Array of terms in tree format. + */ +export function buildTermsTree( flatTerms ) { + const flatTermsWithParentAndChildren = flatTerms.map( ( term ) => { + return { + children: [], + parent: null, + ...term, + }; + } ); + + const termsByParent = groupBy( flatTermsWithParentAndChildren, 'parent' ); + if ( termsByParent.null && termsByParent.null.length ) { + return flatTermsWithParentAndChildren; + } + const fillWithChildren = ( terms ) => { + return terms.map( ( term ) => { + const children = termsByParent[ term.id ]; + return { + ...term, + children: children && children.length ? + fillWithChildren( children ) : + [], + }; + } ); + }; + + return fillWithChildren( termsByParent[ '0' ] || [] ); +} diff --git a/gb-src/packages/components/src/query-controls/test/terms.js b/gb-src/packages/components/src/query-controls/test/terms.js new file mode 100644 index 0000000000000..11cf1505d4f27 --- /dev/null +++ b/gb-src/packages/components/src/query-controls/test/terms.js @@ -0,0 +1,61 @@ +/** + * Internal dependencies + */ +import { buildTermsTree } from '../terms'; + +describe( 'buildTermsTree()', () => { + it( 'Should return same array as input with null parent and empty children added if parent is never specified.', () => { + const input = Object.freeze( [ + { id: 2232, dummy: true }, + { id: 2245, dummy: true }, + ] ); + const output = Object.freeze( [ + { id: 2232, parent: null, children: [], dummy: true }, + { id: 2245, parent: null, children: [], dummy: true }, + ] ); + const termsTreem = buildTermsTree( input ); + expect( termsTreem ).toEqual( output ); + } ); + it( 'Should return same array as input with empty children added if all the elements are top level', () => { + const input = Object.freeze( [ + { id: 2232, parent: 0, dummy: true }, + { id: 2245, parent: 0, dummy: false }, + ] ); + const output = [ + { id: 2232, parent: 0, children: [], dummy: true }, + { id: 2245, parent: 0, children: [], dummy: false }, + ]; + const termsTreem = buildTermsTree( input ); + expect( termsTreem ).toEqual( output ); + } ); + it( 'Should return element with its child if a child exists', () => { + const input = Object.freeze( [ + { id: 2232, parent: 0 }, + { id: 2245, parent: 2232 }, + ] ); + const output = [ + { id: 2232, parent: 0, children: [ + { id: 2245, parent: 2232, children: [] }, + ] }, + ]; + const termsTreem = buildTermsTree( input ); + expect( termsTreem ).toEqual( output ); + } ); + it( 'Should return elements with multiple children and elements with no children', () => { + const input = Object.freeze( [ + { id: 2232, parent: 0 }, + { id: 2245, parent: 2232 }, + { id: 2249, parent: 0 }, + { id: 2246, parent: 2232 }, + ] ); + const output = [ + { id: 2232, parent: 0, children: [ + { id: 2245, parent: 2232, children: [] }, + { id: 2246, parent: 2232, children: [] }, + ] }, + { id: 2249, parent: 0, children: [] }, + ]; + const termsTreem = buildTermsTree( input ); + expect( termsTreem ).toEqual( output ); + } ); +} ); diff --git a/gb-src/packages/components/src/radio-control/README.md b/gb-src/packages/components/src/radio-control/README.md new file mode 100644 index 0000000000000..2a55a81718fb1 --- /dev/null +++ b/gb-src/packages/components/src/radio-control/README.md @@ -0,0 +1,123 @@ +# RadioControl + +Use radio buttons when you want users to select one option from a set, and you want to show them all the available options at once. + +![](https://make.wordpress.org/design/files/2018/11/radio.png) +Selected and unselected radio buttons + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +#### When to use radio buttons + +Use radio buttons when you want users to: + +- Select a single option from a list. +- Expose all available options. + +If you have a list of available options that can be collapsed, consider using a dropdown menu instead, as dropdowns use less space. A country selection field, for instance, would be very large as a group of radio buttons and wouldn't help the user gain more context by seeing all options at once. + +#### Do + +![](https://make.wordpress.org/design/files/2018/11/radio-usage-do.png) +Use radio buttons when only one item can be selected from a list. + +#### Don’t + +![](https://make.wordpress.org/design/files/2018/11/radio-usage-dont.png) +Don’t use checkboxes when only one item can be selected from a list. Use radio buttons instead. + +#### Defaults + +When using radio buttons **one should be selected by default** (i.e., when the page loads, in the case of a web application). + +##### User control + +In most interactions, a user should be able to undo and redo their actions. With most selection controls you can un-choose a selection, but in this instance you cannot click or tap a selected radio button to deselect it—selecting is a final action. The finality isn’t conveyed when none are selected by default. Selecting a radio button by default communicates that the user is required to choose one in the set. + +##### Expediting tasks + +When one a choice in a set of radio buttons is the most desirable or frequently selected, it’s helpful to select it by default. Doing this reduces the interaction cost and can save the user time and clicks. + +##### The power of suggestion + +Designs with a radio button selected by default make a strong suggestion to the user. It can help them make the best decision and increase their confidence. (Use this guidance with caution, and only for good.) + +## Development guidelines + +### Usage + +Render a user interface to select the user type using radio inputs. + +```jsx +import { RadioControl } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyRadioControl = withState( { + option: 'a', +} )( ( { option, setState } ) => ( + <RadioControl + label="User type" + help="The type of the current user" + selected={ option } + options={ [ + { label: 'Author', value: 'a' }, + { label: 'Editor', value: 'e' }, + ] } + onChange={ ( option ) => { setState( { option } ) } } + /> +) ); +``` + +### Props + +The component accepts the following props: + +#### label + +If this property is added, a label will be generated using label property as the content. + +- Type: `String` +- Required: No + +#### help + +If this property is added, a help text will be generated using help property as the content. + +- Type: `String|WPElement` +- Required: No + +#### selected + +The value property of the currently selected option. + +- Type: `Object` +- Required: No + +#### options + +An array of objects containing the following properties: +* `label`: (string) The label to be shown to the user. +* `value`: (Object) The internal value compared against select and passed to onChange. + +- Type: `Array` +- Required: No + +#### onChange + +A function that receives the value of the new option that is being selected as input. + +- Type: `function` +- Required: Yes + +## Related components + +* To select one or more items from a set, use the `CheckboxControl` component. +* To toggle a single setting on or off, use the `ToggleControl` component. diff --git a/gb-src/packages/components/src/radio-control/index.js b/gb-src/packages/components/src/radio-control/index.js new file mode 100644 index 0000000000000..2aec3eaca43d4 --- /dev/null +++ b/gb-src/packages/components/src/radio-control/index.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BaseControl from '../base-control'; + +function RadioControl( { label, className, selected, help, instanceId, onChange, options = [] } ) { + const id = `inspector-radio-control-${ instanceId }`; + const onChangeValue = ( event ) => onChange( event.target.value ); + + return ! isEmpty( options ) && ( + <BaseControl label={ label } id={ id } help={ help } className={ classnames( className, 'components-radio-control' ) }> + { options.map( ( option, index ) => + <div + key={ `${ id }-${ index }` } + className="components-radio-control__option" + > + <input + id={ `${ id }-${ index }` } + className="components-radio-control__input" + type="radio" + name={ id } + value={ option.value } + onChange={ onChangeValue } + checked={ option.value === selected } + aria-describedby={ !! help ? `${ id }__help` : undefined } + /> + <label htmlFor={ `${ id }-${ index }` }> + { option.label } + </label> + </div> + ) } + </BaseControl> + ); +} + +export default withInstanceId( RadioControl ); diff --git a/gb-src/packages/components/src/radio-control/style.scss b/gb-src/packages/components/src/radio-control/style.scss new file mode 100644 index 0000000000000..6d93d4bf146bb --- /dev/null +++ b/gb-src/packages/components/src/radio-control/style.scss @@ -0,0 +1,17 @@ +.components-radio-control { + display: flex; + flex-direction: column; + + .components-base-control__help { + margin-top: 0; + } +} + +.components-radio-control__option:not(:last-child) { + margin-bottom: 4px; +} + +.components-radio-control__input[type="radio"] { + margin-top: 0; + margin-right: 6px; +} diff --git a/gb-src/packages/components/src/range-control/README.md b/gb-src/packages/components/src/range-control/README.md new file mode 100644 index 0000000000000..b0511fdaec2d7 --- /dev/null +++ b/gb-src/packages/components/src/range-control/README.md @@ -0,0 +1,189 @@ +# RangeControl + +RangeControls are used to make selections from a range of incremental values. + +![](https://make.wordpress.org/design/files/2018/12/rangecontrol.png) + +A RangeControl for volume + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Anatomy + +![](https://make.wordpress.org/design/files/2018/12/rangecontrol-anatomy.png) + +A RangeControl can contain the following elements: + +1. **Track**: The track shows the range available for user selection. For left-to-right (LTR) languages, the smallest value appears on the far left, and the largest value on the far right. For right-to-left (RTL) languages this orientation is reversed, with the smallest value on the far right and the largest value on the far left. +2. **Thumb**: The thumb slides along the track, displaying the selected value through its position. +3. **Value entry field** (optional): The value entry field displays the currently selected, specific numerical value. +4. **Icon** (optional): An icon can be displayed before or after the slider. +5. **Tick mark** (optional): Tick marks represent predetermined values to which the user can move the slider. + +### Types + +#### Continuous sliders + +Continuous sliders allow users to select a value along a subjective range. They do not display the selected numeric value. Use them when displaying/editing the numeric value is not important, like volume. + +#### Discrete sliders + +Discrete sliders can be adjusted to a specific value by referencing its value entry field. Use them when it’s important to display/edit the numeric value, like text size. + +Possible selections may be organized through the use of tick marks, which a thumb will snap to (or to which an input will round up or down). + +### Behavior + +- **Click and drag**: The slider is controlled by clicking the thumb and dragging it. +- **Click jump**: The slider is controlled by clicking the track. +- **Click and arrow**: The slider is controlled by clicking the thumb, then using arrow keys to move it. +- **Tab and arrow**: The slider is controlled by using the tab key to select the thumb of the desired slider, then using arrow keys to move it. +- **Tick marks** (Optional) Discrete sliders can use evenly spaced tick marks along the slider track, and the thumb will snap to them. Each tick mark should change the setting in increments that are discernible to the user. +- **Value entry field** (Optional): Discrete sliders have value entry fields. After a text entry is made, the slider position automatically updates to reflect the new value. + +### Usage + +RangeControls reflect a range of values along a track, from which users may select a single value. They are ideal for adjusting settings such as volume, opacity, or text size. + +RangeControls can have icons on both ends of the track that reflect a range of values. + +#### Immediate effects + +Changes made with RangeControls are immediate, allowing a user to make adjustments until finding their preference. They shouldn’t be paired with settings that have delays in providing feedback. + +![A RangeControl that requires a save action](https://make.wordpress.org/design/files/2018/12/rangecontrol-save-action.png) + +**Don’t** +Don’t use RangeControls if the effect isn’t immediate. + +#### Current state + +RangeControls reflect the current state of the settings they control. + +#### Values + +![](https://make.wordpress.org/design/files/2018/12/rangecontrol-field.png) + +A RangeControl with an editable numeric value + +**Editable numeric values**: Editable numeric values allow users to set the exact value of a RangeControl. After setting a value, the thumb position is immediately updated to match the new value. + +![A RangeControl with only two values](https://make.wordpress.org/design/files/2018/12/rangecontrol-2-values.png) + +**Don’t** +RangeControls should only be used for choosing selections from a range of values (e.g., don’t use a RangeControl if there are only 2 values). + +![](https://make.wordpress.org/design/files/2018/12/rangecontrol-disabled.png) + +**Don’t** +RangeControls should provide the full range of choices available for the user to select from (e.g., don’t disable only part of a RangeControl). + +## Development guidelines + +### Usage + +Render a RangeControl to make a selection from a range of incremental values. + +```jsx +import { RangeControl } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyRangeControl = withState( { + columns: 2, +} )( ( { columns, setState } ) => ( + <RangeControl + label="Columns" + value={ columns } + onChange={ ( columns ) => setState( { columns } ) } + min={ 2 } + max={ 10 } + /> +) ); +``` + +### Props + +The set of props accepted by the component will be specified below. +Props not included in this set will be applied to the input elements. + +#### label + +If this property is added, a label will be generated using label property as the content. + +- Type: `String` +- Required: No + +#### help + +If this property is added, a help text will be generated using help property as the content. + +- Type: `String|WPElement` +- Required: No + +#### beforeIcon + +If this property is added, a DashIcon component will be rendered before the slider with the icon equal to beforeIcon + +- Type: `String` +- Required: No + +#### afterIcon + +If this property is added, a DashIcon component will be rendered after the slider with the icon equal to afterIcon + +- Type: `String` +- Required: No + +#### allowReset + +If this property is true, a button to reset the the slider is rendered. + +- Type: `Boolean` +- Required: No + +#### initialPosition + +If no value exists this prop contains the slider starting position. + +- Type: `Number` +- Required: No + +#### value + +The current value of the range slider. + +- Type: `Number` +- Required: Yes + +#### onChange + +A function that receives the new value. +If allowReset is true, when onChange is called without any parameter passed it should reset the value. + +- Type: `function` +- Required: Yes + +#### min + +The minimum value accepted. If smaller values are inserted onChange will not be called and the value gets reverted when blur event fires. + +- Type: `Number` +- Required: No + + +#### max + +The maximum value accepted. If higher values are inserted onChange will not be called and the value gets reverted when blur event fires. + +- Type: `Number` +- Required: No + +## Related components + +- To collect a numerical input in a text field, use the `TextControl` component. diff --git a/gb-src/packages/components/src/range-control/index.js b/gb-src/packages/components/src/range-control/index.js new file mode 100644 index 0000000000000..3b33f2b499c34 --- /dev/null +++ b/gb-src/packages/components/src/range-control/index.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { isFinite } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose, withInstanceId, withState } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { BaseControl, Button, Dashicon } from '../'; + +function RangeControl( { + className, + currentInput, + label, + value, + instanceId, + onChange, + beforeIcon, + afterIcon, + help, + allowReset, + initialPosition, + min, + max, + setState, + ...props +} ) { + const id = `inspector-range-control-${ instanceId }`; + const currentInputValue = currentInput === null ? value : currentInput; + const resetValue = () => { + resetCurrentInput(); + onChange(); + }; + const resetCurrentInput = () => { + if ( currentInput !== null ) { + setState( { + currentInput: null, + } ); + } + }; + + const onChangeValue = ( event ) => { + const newValue = event.target.value; + // If the input value is invalid temporarily save it to the state, + // without calling on change. + if ( ! event.target.checkValidity() ) { + setState( { + currentInput: newValue, + } ); + return; + } + // The input is valid, reset the local state property used to temporaly save the value, + // and call onChange with the new value as a number. + resetCurrentInput(); + onChange( ( newValue === '' ) ? + undefined : + parseFloat( newValue ) + ); + }; + const initialSliderValue = isFinite( currentInputValue ) ? + currentInputValue : + initialPosition || ''; + + return ( + <BaseControl + label={ label } + id={ id } + help={ help } + className={ classnames( 'components-range-control', className ) } + > + { beforeIcon && <Dashicon icon={ beforeIcon } /> } + <input + className="components-range-control__slider" + id={ id } + type="range" + value={ initialSliderValue } + onChange={ onChangeValue } + aria-describedby={ !! help ? id + '__help' : undefined } + min={ min } + max={ max } + { ...props } /> + { afterIcon && <Dashicon icon={ afterIcon } /> } + <input + className="components-range-control__number" + type="number" + onChange={ onChangeValue } + aria-label={ label } + value={ currentInputValue } + min={ min } + max={ max } + onBlur={ resetCurrentInput } + { ...props } + /> + { allowReset && ( + <Button + onClick={ resetValue } + disabled={ value === undefined } + isSmall + isDefault + className="components-range-control__reset" + > + { __( 'Reset' ) } + </Button> + ) } + </BaseControl> + ); +} + +export default compose( [ + withInstanceId, + withState( { + currentInput: null, + } ), +] )( RangeControl ); diff --git a/gb-src/packages/components/src/range-control/style.scss b/gb-src/packages/components/src/range-control/style.scss new file mode 100644 index 0000000000000..b67e503592650 --- /dev/null +++ b/gb-src/packages/components/src/range-control/style.scss @@ -0,0 +1,129 @@ + +.components-range-control { + .components-base-control__field { + display: flex; + justify-content: center; + flex-wrap: wrap; + align-items: center; + } + + .dashicon { + flex-shrink: 0; + margin-right: 10px; + } + + .components-base-control__label { + width: 100%; + } + + .components-range-control__slider { + margin-left: 0; + flex: 1; + } +} + +.components-range-control__reset { + margin-left: $grid-size; +} + +// creating mixin because we can't do multiline variables, and we can't comma-group the selectors for styling the range slider +@mixin range-thumb() { + height: 18px; + width: 18px; + border-radius: 50%; + cursor: pointer; + background: $dark-gray-500; + border: 4px solid transparent; + background-clip: padding-box; + box-sizing: border-box; +} + +@mixin range-track() { + height: 3px; + cursor: pointer; + background: $light-gray-500; + border-radius: 1.5px; +} + +.components-range-control__slider { + width: 100%; + margin-left: $grid-size; + padding: 0; + -webkit-appearance: none; + background: transparent; + + /** + * Thumb + */ + + // webkit + &::-webkit-slider-thumb { + -webkit-appearance: none; + @include range-thumb(); + margin-top: -7px; // necessary for chrome + } + + // moz + &::-moz-range-thumb { + @include range-thumb(); + } + + // ie + &::-ms-thumb { + @include range-thumb(); + margin-top: 0; // necessary because edge inherits from chrome + height: 14px; + width: 14px; + border: 2px solid transparent; + } + + &:focus { + outline: none; + } + + // webkit + &:focus::-webkit-slider-thumb { + @include button-style__focus-active; + } + + // moz + &:focus::-moz-range-thumb { + @include button-style__focus-active; + } + + // ie + &:focus::-ms-thumb { + @include button-style__focus-active; + } + + /** + * Track + */ + + // webkit + &::-webkit-slider-runnable-track { + @include range-track(); + margin-top: -4px; + } + + // moz + &::-moz-range-track { + @include range-track(); + } + + // ie + &::-ms-track { + margin-top: -4px; + background: transparent; + border-color: transparent; + color: transparent; + @include range-track(); + } +} + +.components-range-control__number { + display: inline-block; + margin-left: $grid-size; + font-weight: 500; + width: 54px; +} diff --git a/gb-src/packages/components/src/range-control/test/index.js b/gb-src/packages/components/src/range-control/test/index.js new file mode 100644 index 0000000000000..e1308c06363e4 --- /dev/null +++ b/gb-src/packages/components/src/range-control/test/index.js @@ -0,0 +1,310 @@ +/** + * External dependencies + */ +import TestUtils from 'react-dom/test-utils'; + +/** + * Internal dependencies + */ +import RangeControl from '../'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { Dashicon } from '@wordpress/components'; + +describe( 'RangeControl', () => { + class TestWrapper extends Component { + render() { + return <RangeControl { ...this.props } />; + } + } + + const getWrapper = ( props = {} ) => TestUtils.renderIntoDocument( + <TestWrapper { ...props } /> + ); + + describe( '#render()', () => { + it( 'triggers change callback with numeric value', () => { + // Mount: With shallow, cannot find input child of BaseControl + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange } ); + + const rangeInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__slider' + ); + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + TestUtils.Simulate.change( + rangeInputElement(), + { + target: { + value: '5', + checkValidity() { + return true; + }, + }, + } + ); + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '10', + checkValidity() { + return true; + }, + }, + } + ); + + expect( onChange ).toHaveBeenCalledWith( 5 ); + expect( onChange ).toHaveBeenCalledWith( 10 ); + } ); + + it( 'renders with icons', () => { + let wrapper, icons; + const iconElements = ( component ) => TestUtils + .scryRenderedComponentsWithType( component, Dashicon ); + wrapper = getWrapper(); + icons = iconElements( wrapper ); + expect( icons ).toHaveLength( 0 ); + + wrapper = getWrapper( { beforeIcon: 'format-image' } ); + icons = iconElements( wrapper ); + expect( icons ).toHaveLength( 1 ); + expect( icons[ 0 ].props.icon ).toBe( 'format-image' ); + + wrapper = getWrapper( + { + beforeIcon: 'format-image', + afterIcon: 'format-video', + } + ); + icons = iconElements( wrapper ); + expect( icons ).toHaveLength( 2 ); + expect( icons[ 0 ].props.icon ).toBe( 'format-image' ); + expect( icons[ 1 ].props.icon ).toBe( 'format-video' ); + } ); + } ); + + describe( 'validation', () => { + it( 'does not calls onChange if the new value is lower than minimum', () => { + // Mount: With shallow, cannot find input child of BaseControl + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, min: 11, value: 12 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '10', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + } ); + + it( 'does not calls onChange if the new value is greater than maximum', () => { + // Mount: With shallow, cannot find input child of BaseControl + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, max: 20, value: 12 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '21', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + } ); + + it( 'calls onChange after invalid inputs if the new input is valid', () => { + // Mount: With shallow, cannot find input child of BaseControl + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, min: 11, max: 20, value: 12 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '10', + checkValidity() { + return false; + }, + }, + } + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '21', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '14', + checkValidity() { + return true; + }, + }, + } + ); + + expect( onChange ).toHaveBeenCalledWith( 14 ); + } ); + + it( 'validates when provided a max or min of zero', () => { + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, min: -100, max: 0, value: 0 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '1', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + } ); + + it( 'validates when min and max are negative', () => { + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, min: -100, max: -50, value: -60 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '-101', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '-49', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '-50', + checkValidity() { + return true; + }, + }, + } + ); + + expect( onChange ).toHaveBeenCalledWith( -50 ); + } ); + it( 'takes into account the step starting from min', () => { + const onChange = jest.fn(); + const wrapper = getWrapper( { onChange, min: 0.1, step: 0.125, value: 0.1 } ); + + const numberInputElement = () => TestUtils.findRenderedDOMComponentWithClass( + wrapper, + 'components-range-control__number' + ); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '0.125', + checkValidity() { + return false; + }, + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + + TestUtils.Simulate.change( + numberInputElement(), + { + target: { + value: '0.225', + checkValidity() { + return true; + }, + }, + } + ); + + expect( onChange ).toHaveBeenCalledWith( 0.225 ); + } ); + } ); +} ); diff --git a/gb-src/packages/components/src/resizable-box/README.md b/gb-src/packages/components/src/resizable-box/README.md new file mode 100644 index 0000000000000..42302f93b08b0 --- /dev/null +++ b/gb-src/packages/components/src/resizable-box/README.md @@ -0,0 +1,55 @@ +# ResizableBox + +ResizableBox is a wrapper around the [re-resizable package](https://github.com/bokuweb/re-resizable) with pre-defined classes and styles. + +## Usage + +Most options are passed directly through to [re-resizable](https://github.com/bokuweb/re-resizable) so you may wish to refer to their documentation. The primary differences in this component are that we set `handleClasses` (to use custom class names) and pass some null values to `handleStyles` (to unset some inline styles). + +The example below shows how you might use `ResizableBox` to set a width and height inside a block's `edit` component. + +```jsx +import { ResizableBox } from '@wordpress/components'; + +const Edit = ( props ) => { + const { + attributes: { + height, + width, + }, + setAttributes, + toggleSelection, + } = props; + + return ( + <ResizableBox + size={ { + height, + width, + } } + minHeight="50" + minWidth="50" + enable={ { + top: false, + right: true, + bottom: true, + left: false, + topRight: false, + bottomRight: true, + bottomLeft: false, + topLeft: false, + } } + onResizeStop={ ( event, direction, elt, delta ) => { + setAttributes( { + height: parseInt( height + delta.height, 10 ), + width: parseInt( width + delta.width, 10 ), + } ); + toggleSelection( true ); + } } + onResizeStart={ () => { + toggleSelection( false ); + } } + /> + ); +} +``` diff --git a/gb-src/packages/components/src/resizable-box/index.js b/gb-src/packages/components/src/resizable-box/index.js new file mode 100644 index 0000000000000..fc63dd126a24e --- /dev/null +++ b/gb-src/packages/components/src/resizable-box/index.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { Resizable } from 're-resizable'; + +function ResizableBox( { className, ...props } ) { + // Removes the inline styles in the drag handles. + const handleStylesOverrides = { + width: null, + height: null, + top: null, + right: null, + bottom: null, + left: null, + }; + + const handleClassName = 'components-resizable-box__handle'; + const sideHandleClassName = 'components-resizable-box__side-handle'; + const cornerHandleClassName = 'components-resizable-box__corner-handle'; + + return ( + <Resizable + className={ classnames( + 'components-resizable-box__container', + className, + ) } + handleClasses={ { + top: classnames( + handleClassName, + sideHandleClassName, + 'components-resizable-box__handle-top', + ), + right: classnames( + handleClassName, + sideHandleClassName, + 'components-resizable-box__handle-right', + ), + bottom: classnames( + handleClassName, + sideHandleClassName, + 'components-resizable-box__handle-bottom', + ), + left: classnames( + handleClassName, + sideHandleClassName, + 'components-resizable-box__handle-left', + ), + topLeft: classnames( + handleClassName, + cornerHandleClassName, + 'components-resizable-box__handle-top', + 'components-resizable-box__handle-left', + ), + topRight: classnames( + handleClassName, + cornerHandleClassName, + 'components-resizable-box__handle-top', + 'components-resizable-box__handle-right', + ), + bottomRight: classnames( + handleClassName, + cornerHandleClassName, + 'components-resizable-box__handle-bottom', + 'components-resizable-box__handle-right', + ), + bottomLeft: classnames( + handleClassName, + cornerHandleClassName, + 'components-resizable-box__handle-bottom', + 'components-resizable-box__handle-left', + ), + } } + handleStyles={ { + top: handleStylesOverrides, + right: handleStylesOverrides, + bottom: handleStylesOverrides, + left: handleStylesOverrides, + topLeft: handleStylesOverrides, + topRight: handleStylesOverrides, + bottomRight: handleStylesOverrides, + bottomLeft: handleStylesOverrides, + } } + { ...props } + /> + ); +} + +export default ResizableBox; diff --git a/gb-src/packages/components/src/resizable-box/style.scss b/gb-src/packages/components/src/resizable-box/style.scss new file mode 100644 index 0000000000000..4297038291124 --- /dev/null +++ b/gb-src/packages/components/src/resizable-box/style.scss @@ -0,0 +1,141 @@ +// This is a wrapper of the actual visible handle (pseudo element). It is styled +// to be much bigger than the visual part so it's easier to click and use. +.components-resizable-box__handle { + display: none; + width: $resize-handler-container-size; + height: $resize-handler-container-size; + + // Show the resize handle when selected. + .components-resizable-box__container.is-selected & { + display: block; + } +} + +// This is the "visible" resize handle - the circle part +.components-resizable-box__handle::after { + display: block; + content: ""; + width: $resize-handler-size; + height: $resize-handler-size; + border: 2px solid $white; + border-radius: 50%; + background: theme(primary); + cursor: inherit; + position: absolute; + top: calc(50% - #{ceil($resize-handler-size / 2)}); + right: calc(50% - #{ceil($resize-handler-size / 2)}); +} + +// This is the "visible" resize handle for side handles - the line +.components-resizable-box__side-handle::before { + display: block; + content: ""; + width: 7px; + height: 7px; + border: 2px solid $white; + background: theme(primary); + cursor: inherit; + position: absolute; + top: calc(50% - 4px); + right: calc(50% - 4px); + transition: transform 0.1s ease-in; + @include reduce-motion("transition"); + opacity: 0; +} + +.is-dark-theme { + .components-resizable-box__side-handle::before, + .components-resizable-box__handle::after { + border-color: $light-gray-600; + } +} + +// Show corner handles on top of side handles so they can be used +.components-resizable-box__side-handle { + z-index: z-index(".components-resizable-box__side-handle"); +} + +.components-resizable-box__corner-handle { + z-index: z-index(".components-resizable-box__corner-handle"); +} + +// Make horizontal side-handles full width +.components-resizable-box__side-handle.components-resizable-box__handle-top, +.components-resizable-box__side-handle.components-resizable-box__handle-bottom, +.components-resizable-box__side-handle.components-resizable-box__handle-top::before, +.components-resizable-box__side-handle.components-resizable-box__handle-bottom::before { + width: 100%; + left: 0; + border-left: 0; + border-right: 0; +} + +// Make vertical side-handles full height +.components-resizable-box__side-handle.components-resizable-box__handle-left, +.components-resizable-box__side-handle.components-resizable-box__handle-right, +.components-resizable-box__side-handle.components-resizable-box__handle-left::before, +.components-resizable-box__side-handle.components-resizable-box__handle-right::before { + height: 100%; + top: 0; + border-top: 0; + border-bottom: 0; +} + +// Reveal the side handle line when hovered or in use +.components-resizable-box__side-handle.components-resizable-box__handle-top:hover::before, +.components-resizable-box__side-handle.components-resizable-box__handle-bottom:hover::before, +.components-resizable-box__side-handle.components-resizable-box__handle-top:active::before, +.components-resizable-box__side-handle.components-resizable-box__handle-bottom:active::before { + animation: components-resizable-box__top-bottom-animation 0.1s ease-out 0s; + animation-fill-mode: forwards; + @include reduce-motion("animation"); +} + +.components-resizable-box__side-handle.components-resizable-box__handle-left:hover::before, +.components-resizable-box__side-handle.components-resizable-box__handle-right:hover::before, +.components-resizable-box__side-handle.components-resizable-box__handle-left:active::before, +.components-resizable-box__side-handle.components-resizable-box__handle-right:active::before { + animation: components-resizable-box__left-right-animation 0.1s ease-out 0s; + animation-fill-mode: forwards; + @include reduce-motion("animation"); +} + +@keyframes components-resizable-box__top-bottom-animation { + from { + transform: scaleX(0); + opacity: 0; + } + to { + transform: scaleX(1); + opacity: 1; + } +} + +@keyframes components-resizable-box__left-right-animation { + from { + transform: scaleY(0); + opacity: 0; + } + to { + transform: scaleY(1); + opacity: 1; + } +} + +/*!rtl:begin:ignore*/ +.components-resizable-box__handle-right { + right: calc(#{$resize-handler-container-size / 2} * -1); +} + +.components-resizable-box__handle-left { + left: calc(#{$resize-handler-container-size / 2} * -1); +} + +.components-resizable-box__handle-top { + top: calc(#{$resize-handler-container-size / 2} * -1); +} + +.components-resizable-box__handle-bottom { + bottom: calc(#{$resize-handler-container-size / 2} * -1); +} +/*!rtl:end:ignore*/ diff --git a/gb-src/packages/components/src/responsive-wrapper/README.md b/gb-src/packages/components/src/responsive-wrapper/README.md new file mode 100644 index 0000000000000..ce325e732ce0e --- /dev/null +++ b/gb-src/packages/components/src/responsive-wrapper/README.md @@ -0,0 +1,16 @@ +# ResponsiveWrapper + +## Usage + +```jsx +import { ResponsiveWrapper } from '@wordpress/components'; + +const MyResponsiveWrapper = () => ( + <ResponsiveWrapper + naturalWidth={ 2000 } + naturalHeight={ 680 } + > + <img src="https://s.w.org/style/images/about/WordPress-logotype-standard.png" alt="WordPress" /> + </ResponsiveWrapper> +); +``` diff --git a/gb-src/packages/components/src/responsive-wrapper/index.js b/gb-src/packages/components/src/responsive-wrapper/index.js new file mode 100644 index 0000000000000..4c5dfca6ec363 --- /dev/null +++ b/gb-src/packages/components/src/responsive-wrapper/index.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { cloneElement, Children } from '@wordpress/element'; + +function ResponsiveWrapper( { + naturalWidth, + naturalHeight, + children, + isInline = false, +} ) { + if ( Children.count( children ) !== 1 ) { + return null; + } + const imageStyle = { + paddingBottom: ( naturalHeight / naturalWidth * 100 ) + '%', + }; + const TagName = isInline ? 'span' : 'div'; + return ( + <TagName className="components-responsive-wrapper"> + <TagName style={ imageStyle } /> + { cloneElement( children, { + className: classnames( 'components-responsive-wrapper__content', children.props.className ), + } ) } + </TagName> + ); +} + +export default ResponsiveWrapper; diff --git a/gb-src/packages/components/src/responsive-wrapper/style.scss b/gb-src/packages/components/src/responsive-wrapper/style.scss new file mode 100644 index 0000000000000..c51288172fbcb --- /dev/null +++ b/gb-src/packages/components/src/responsive-wrapper/style.scss @@ -0,0 +1,18 @@ +.components-responsive-wrapper { + position: relative; + max-width: 100%; + &, + & > span { + display: block; + } +} + +.components-responsive-wrapper__content { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/gb-src/packages/components/src/sandbox/README.md b/gb-src/packages/components/src/sandbox/README.md new file mode 100644 index 0000000000000..7b1b5cafdb1b9 --- /dev/null +++ b/gb-src/packages/components/src/sandbox/README.md @@ -0,0 +1,17 @@ +# Sandbox + +This component provides an isolated environment for arbitrary HTML via iframes. + +## Usage + +```jsx +import { SandBox } from '@wordpress/components'; + +const MySandBox = () => ( + <SandBox + html="<p>Content</p>" + title="Sandbox" + type="embed" + /> +); +``` diff --git a/gb-src/packages/components/src/sandbox/index.js b/gb-src/packages/components/src/sandbox/index.js new file mode 100644 index 0000000000000..a607d31487cc2 --- /dev/null +++ b/gb-src/packages/components/src/sandbox/index.js @@ -0,0 +1,221 @@ +/** + * WordPress dependencies + */ +import { Component, renderToString, createRef } from '@wordpress/element'; +import { withGlobalEvents } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import FocusableIframe from '../focusable-iframe'; + +class Sandbox extends Component { + constructor() { + super( ...arguments ); + + this.trySandbox = this.trySandbox.bind( this ); + this.checkMessageForResize = this.checkMessageForResize.bind( this ); + + this.iframe = createRef(); + + this.state = { + width: 0, + height: 0, + }; + } + + componentDidMount() { + this.trySandbox(); + } + + componentDidUpdate() { + this.trySandbox(); + } + + isFrameAccessible() { + try { + return !! this.iframe.current.contentDocument.body; + } catch ( e ) { + return false; + } + } + + checkMessageForResize( event ) { + const iframe = this.iframe.current; + + // Attempt to parse the message data as JSON if passed as string + let data = event.data || {}; + if ( 'string' === typeof data ) { + try { + data = JSON.parse( data ); + } catch ( e ) {} + } + + // Verify that the mounted element is the source of the message + if ( ! iframe || iframe.contentWindow !== event.source ) { + return; + } + + // Update the state only if the message is formatted as we expect, i.e. + // as an object with a 'resize' action, width, and height + const { action, width, height } = data; + const { width: oldWidth, height: oldHeight } = this.state; + + if ( 'resize' === action && ( oldWidth !== width || oldHeight !== height ) ) { + this.setState( { width, height } ); + } + } + + trySandbox() { + if ( ! this.isFrameAccessible() ) { + return; + } + + const body = this.iframe.current.contentDocument.body; + if ( null !== body.getAttribute( 'data-resizable-iframe-connected' ) ) { + return; + } + + const observeAndResizeJS = ` + ( function() { + var observer; + + if ( ! window.MutationObserver || ! document.body || ! window.parent ) { + return; + } + + function sendResize() { + var clientBoundingRect = document.body.getBoundingClientRect(); + + window.parent.postMessage( { + action: 'resize', + width: clientBoundingRect.width, + height: clientBoundingRect.height, + }, '*' ); + } + + observer = new MutationObserver( sendResize ); + observer.observe( document.body, { + attributes: true, + attributeOldValue: false, + characterData: true, + characterDataOldValue: false, + childList: true, + subtree: true + } ); + + window.addEventListener( 'load', sendResize, true ); + + // Hack: Remove viewport unit styles, as these are relative + // the iframe root and interfere with our mechanism for + // determining the unconstrained page bounds. + function removeViewportStyles( ruleOrNode ) { + if( ruleOrNode.style ) { + [ 'width', 'height', 'minHeight', 'maxHeight' ].forEach( function( style ) { + if ( /^\\d+(vmin|vmax|vh|vw)$/.test( ruleOrNode.style[ style ] ) ) { + ruleOrNode.style[ style ] = ''; + } + } ); + } + } + + Array.prototype.forEach.call( document.querySelectorAll( '[style]' ), removeViewportStyles ); + Array.prototype.forEach.call( document.styleSheets, function( stylesheet ) { + Array.prototype.forEach.call( stylesheet.cssRules || stylesheet.rules, removeViewportStyles ); + } ); + + document.body.style.position = 'absolute'; + document.body.style.width = '100%'; + document.body.setAttribute( 'data-resizable-iframe-connected', '' ); + + sendResize(); + + // Resize events can change the width of elements with 100% width, but we don't + // get an DOM mutations for that, so do the resize when the window is resized, too. + window.addEventListener( 'resize', sendResize, true ); + } )();`; + + const style = ` + body { + margin: 0; + } + html, + body, + body > div, + body > div > iframe { + width: 100%; + } + html.wp-has-aspect-ratio, + body.wp-has-aspect-ratio, + body.wp-has-aspect-ratio > div, + body.wp-has-aspect-ratio > div > iframe { + height: 100%; + overflow: hidden; /* If it has an aspect ratio, it shouldn't scroll. */ + } + body > div > * { + margin-top: 0 !important; /* Has to have !important to override inline styles. */ + margin-bottom: 0 !important; + } + `; + + // put the html snippet into a html document, and then write it to the iframe's document + // we can use this in the future to inject custom styles or scripts. + // Scripts go into the body rather than the head, to support embedded content such as Instagram + // that expect the scripts to be part of the body. + const htmlDoc = ( + <html lang={ document.documentElement.lang } className={ this.props.type }> + <head> + <title>{ this.props.title } + + + ` + ); + + // Give the browser time to show the alert. + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + + expect( console ).toHaveWarned(); + expect( console ).toHaveErrored(); + expect( hasAlert ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/keyboard-navigable-blocks.test.js b/gb-src/packages/e2e-tests/specs/keyboard-navigable-blocks.test.js new file mode 100644 index 0000000000000..a8ebe96c34fcd --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/keyboard-navigable-blocks.test.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + insertBlock, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +const navigateToContentEditorTop = async () => { + // Use 'Ctrl+`' to return to the top of the editor + await pressKeyWithModifier( 'ctrl', '`' ); + await pressKeyWithModifier( 'ctrl', '`' ); + + // Tab into the Title block + await page.keyboard.press( 'Tab' ); +}; + +const tabThroughParagraphBlock = async ( paragraphText ) => { + // Tab to the next paragraph block + await page.keyboard.press( 'Tab' ); + + // The block external focusable wrapper has focus + const isFocusedParagraphBlock = await page.evaluate( + () => document.activeElement.dataset.type + ); + await expect( isFocusedParagraphBlock ).toEqual( 'core/paragraph' ); + + // Tab causes 'add block' button to receive focus + await page.keyboard.press( 'Tab' ); + const isFocusedParagraphInserterToggle = await page.evaluate( () => + document.activeElement.classList.contains( 'block-editor-inserter__toggle' ) + ); + await expect( isFocusedParagraphInserterToggle ).toBe( true ); + + await tabThroughBlockMoverControl(); + await tabThroughBlockToolbar(); + + // Tab causes the paragraph content to receive focus + await page.keyboard.press( 'Tab' ); + const isFocusedParagraphContent = await page.evaluate( + () => document.activeElement.contentEditable + ); + // The value of 'contentEditable' should be the string 'true' + await expect( isFocusedParagraphContent ).toBe( 'true' ); + + const paragraphEditableContent = await page.evaluate( + () => document.activeElement.innerHTML + ); + await expect( paragraphEditableContent ).toBe( paragraphText ); +}; + +const tabThroughBlockMoverControl = async () => { + // Tab to focus on the 'move up' control + await page.keyboard.press( 'Tab' ); + const isFocusedMoveUpControl = await page.evaluate( () => + document.activeElement.classList.contains( 'block-editor-block-mover__control' ) + ); + await expect( isFocusedMoveUpControl ).toBe( true ); + + // Tab to focus on the 'move down' control + await page.keyboard.press( 'Tab' ); + const isFocusedMoveDownControl = await page.evaluate( () => + document.activeElement.classList.contains( 'block-editor-block-mover__control' ) + ); + await expect( isFocusedMoveDownControl ).toBe( true ); +}; + +const tabThroughBlockToolbar = async () => { + // Tab to focus on the 'block switcher' control + await page.keyboard.press( 'Tab' ); + const isFocusedBlockSwitcherControl = await page.evaluate( () => + document.activeElement.classList.contains( + 'block-editor-block-switcher__toggle' + ) + ); + await expect( isFocusedBlockSwitcherControl ).toBe( true ); + + // Tab to focus on the 'Change text alignment' dropdown control + await page.keyboard.press( 'Tab' ); + const isFocusedChangeTextAlignmentControl = await page.evaluate( () => + document.activeElement.classList.contains( 'components-dropdown-menu__toggle' ) + ); + await expect( isFocusedChangeTextAlignmentControl ).toBe( true ); + + // Tab to focus on the 'More formatting' dropdown toggle + await page.keyboard.press( 'Tab' ); + const isFocusedBlockSettingsDropdown = await page.evaluate( () => + document.activeElement.classList.contains( 'components-dropdown-menu__toggle' ) + ); + await expect( isFocusedBlockSettingsDropdown ).toBe( true ); +}; + +describe( 'Order of block keyboard navigation', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'permits tabbing through paragraph blocks in the expected order', async () => { + const paragraphBlocks = [ 'Paragraph 0', 'Paragraph 1', 'Paragraph 2' ]; + + // create 3 paragraphs blocks with some content + for ( const paragraphBlock of paragraphBlocks ) { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( paragraphBlock ); + await page.keyboard.press( 'Enter' ); + } + + await navigateToContentEditorTop(); + + for ( const paragraphBlock of paragraphBlocks ) { + await tabThroughParagraphBlock( paragraphBlock ); + } + + // Repeat the same steps to ensure that there is no change introduced in how the focus is handled. + // This prevents the previous regression explained in: https://github.com/WordPress/gutenberg/issues/11773. + await navigateToContentEditorTop(); + + for ( const paragraphBlock of paragraphBlocks ) { + await tabThroughParagraphBlock( paragraphBlock ); + } + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/links.test.js b/gb-src/packages/e2e-tests/specs/links.test.js new file mode 100644 index 0000000000000..f7e78a7b68a1e --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/links.test.js @@ -0,0 +1,551 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + clickBlockToolbarButton, + getEditedPostContent, + createNewPost, + pressKeyWithModifier, + insertBlock, +} from '@wordpress/e2e-test-utils'; + +/** + * The modifier keys needed to invoke a 'select the next word' keyboard shortcut. + * + * @type {string} + */ + +describe( 'Links', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + const waitForAutoFocus = async () => { + await page.waitForFunction( () => !! document.activeElement.closest( '.block-editor-url-input' ) ); + }; + + it( 'can be created by selecting text and clicking Link', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Type a URL + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click on the Apply button + await page.click( 'button[aria-label="Apply"]' ); + + // The link should have been inserted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'can be created by selecting text and using keyboard shortcuts', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Type a URL + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Press Enter to apply the link + await page.keyboard.press( 'Enter' ); + + // The link should have been inserted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'can be created without any text selected', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg: ' ); + + // Press escape to show the block toolbar + await page.keyboard.press( 'Escape' ); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Type a URL + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Press Enter to apply the link + await page.keyboard.press( 'Enter' ); + + // A link with the URL as its text should have been inserted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'can be created instantly when a URL is selected', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg: https://wordpress.org/gutenberg' ); + + // Select the URL + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // A link with the selected URL as its href should have been inserted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'is not created when we click away from the link input', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Type a URL + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click somewhere else - it doesn't really matter where + await page.click( '.editor-post-title' ); + } ); + + const createAndReselectLink = async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Type a URL + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click on the Apply button + await page.click( 'button[aria-label="Apply"]' ); + }; + + it( 'can be edited', async () => { + await createAndReselectLink(); + + // Click on the Edit button + await page.click( 'button[aria-label="Edit"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Change the URL + await page.keyboard.type( '/handbook' ); + + // Click on the Apply button + await page.click( 'button[aria-label="Apply"]' ); + + // The link should have been updated + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'can be removed', async () => { + await createAndReselectLink(); + + // Click on the Unlink button + await page.click( 'button[aria-label="Unlink"]' ); + + // The link should have been removed + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + const toggleFixedToolbar = async ( isFixed ) => { + await page.evaluate( ( _isFixed ) => { + const { select, dispatch } = wp.data; + const isCurrentlyFixed = select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); + if ( isCurrentlyFixed !== _isFixed ) { + dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); + } + }, isFixed ); + }; + + it( 'allows Left to be pressed during creation when the toolbar is fixed to top', async () => { + await toggleFixedToolbar( true ); + + await clickBlockAppender(); + await page.keyboard.type( 'Text' ); + await page.click( 'button[aria-label="Link"]' ); + + // Typing "left" should not close the dialog + await page.keyboard.press( 'ArrowLeft' ); + let popover = await page.$( '.block-editor-url-popover' ); + expect( popover ).not.toBeNull(); + + // Escape should close the dialog still. + await page.keyboard.press( 'Escape' ); + popover = await page.$( '.block-editor-url-popover' ); + expect( popover ).toBeNull(); + } ); + + it( 'allows Left to be pressed during creation in "Docked Toolbar" mode', async () => { + await toggleFixedToolbar( false ); + + await clickBlockAppender(); + await page.keyboard.type( 'Text' ); + + await clickBlockToolbarButton( 'Link' ); + + // Typing "left" should not close the dialog + await page.keyboard.press( 'ArrowLeft' ); + let popover = await page.$( '.block-editor-url-popover' ); + expect( popover ).not.toBeNull(); + + // Escape should close the dialog still. + await page.keyboard.press( 'Escape' ); + popover = await page.$( '.block-editor-url-popover' ); + expect( popover ).toBeNull(); + } ); + + it( 'can be edited with collapsed selection', async () => { + await createAndReselectLink(); + // Make a collapsed selection inside the link + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + // Move the mouse to show the block toolbar + await page.mouse.move( 0, 0 ); + await page.mouse.move( 10, 10 ); + await page.click( 'button[aria-label="Edit"]' ); + await waitForAutoFocus(); + await page.keyboard.type( '/handbook' ); + await page.click( 'button[aria-label="Apply"]' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + const createPostWithTitle = async ( titleText ) => { + await createNewPost(); + await page.type( '.editor-post-title__input', titleText ); + await page.click( '.editor-post-publish-panel__toggle' ); + + // Disable reason: Wait for the animation to complete, since otherwise the + // click attempt may occur at the wrong point. + // eslint-disable-next-line no-restricted-syntax + await page.waitFor( 100 ); + + // Publish the post + await page.click( '.editor-post-publish-button' ); + + // Return the URL of the new post + await page.waitForSelector( '.post-publish-panel__postpublish-post-address input' ); + return page.evaluate( () => document.querySelector( '.post-publish-panel__postpublish-post-address input' ).value ); + }; + + // Test for regressions of https://github.com/WordPress/gutenberg/issues/10496. + it.skip( 'allows autocomplete suggestions to be selected with the mouse', async () => { + // First create a post that we can search for using the link autocompletion. + const titleText = 'Test post mouse'; + const postURL = await createPostWithTitle( titleText ); + + // Now create a new post and try to select the post created previously + // from the autocomplete suggestions. + await createNewPost(); + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await page.click( 'button[aria-label="Link"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + await page.keyboard.type( titleText ); + const suggestionXPath = `//*[contains(@class, "block-editor-url-input__suggestion")]//button[contains(text(), '${ titleText }')]`; + await page.waitForXPath( suggestionXPath ); + const autocompleteSuggestions = await page.$x( suggestionXPath ); + + // Expect there to be some autocomplete suggestions. + expect( autocompleteSuggestions ).toHaveLength( 1 ); + + const firstSuggestion = autocompleteSuggestions[ 0 ]; + + // Expect that clicking on the autocomplete suggestion doesn't dismiss the link popover. + await firstSuggestion.click(); + expect( await page.$( '.block-editor-url-popover' ) ).not.toBeNull(); + + // Expect the url input value to have been updated with the post url. + const inputValue = await page.evaluate( () => document.querySelector( '.block-editor-url-input input[aria-label="URL"]' ).value ); + expect( inputValue ).toEqual( postURL ); + + // Expect the link to apply correctly. + // Note - have avoided using snapshots here since the link url can't be determined ahead of time. + await page.click( 'button[aria-label="Apply"]' ); + const linkHref = await page.evaluate( () => document.querySelector( '.block-editor-format-toolbar__link-container-value' ).href ); + expect( linkHref ).toEqual( postURL ); + } ); + + // Test for regressions of https://github.com/WordPress/gutenberg/issues/10496. + // This test isn't reliable on Travis and fails from time to time. + // See: https://github.com/WordPress/gutenberg/pull/15211. + it.skip( 'allows autocomplete suggestions to be navigated with the keyboard', async () => { + const titleText = 'Test post keyboard'; + const postURL = await createPostWithTitle( titleText ); + + await createNewPost(); + await clickBlockAppender(); + + // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. + await page.keyboard.type( 'This is Gutenberg' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + await page.keyboard.type( titleText ); + await page.waitForSelector( '.block-editor-url-input__suggestion' ); + const autocompleteSuggestions = await page.$x( `//*[contains(@class, "block-editor-url-input__suggestion")]//button[contains(text(), '${ titleText }')]` ); + + // Expect there to be some autocomplete suggestions. + expect( autocompleteSuggestions ).toHaveLength( 1 ); + + // Expect the the first suggestion to be selected when pressing the down arrow. + await page.keyboard.press( 'ArrowDown' ); + const isSelected = await page.evaluate( () => document.querySelector( '.block-editor-url-input__suggestion' ).getAttribute( 'aria-selected' ) ); + expect( isSelected ).toBe( 'true' ); + + // Expect the link to apply correctly when pressing Enter. + // Note - have avoided using snapshots here since the link url can't be determined ahead of time. + await page.keyboard.press( 'Enter' ); + const linkHref = await page.evaluate( () => document.querySelector( '.block-editor-format-toolbar__link-container-value' ).href ); + expect( linkHref ).toEqual( postURL ); + } ); + + it( 'allows use of escape key to dismiss the url popover', async () => { + const titleText = 'Test post escape'; + await createPostWithTitle( titleText ); + + await createNewPost(); + await clickBlockAppender(); + + // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. + await page.keyboard.type( 'This is Gutenberg' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + expect( await page.$( '.block-editor-url-popover' ) ).not.toBeNull(); + + // Trigger the autocomplete suggestion list and select the first suggestion. + await page.keyboard.type( titleText ); + await page.waitForSelector( '.block-editor-url-input__suggestion' ); + await page.keyboard.press( 'ArrowDown' ); + + // Expect the the escape key to dismiss the popover when the autocomplete suggestion list is open. + await page.keyboard.press( 'Escape' ); + expect( await page.$( '.block-editor-url-popover' ) ).toBeNull(); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + expect( await page.$( '.block-editor-url-popover' ) ).not.toBeNull(); + + // Expect the the escape key to dismiss the popover normally. + await page.keyboard.press( 'Escape' ); + expect( await page.$( '.block-editor-url-popover' ) ).toBeNull(); + + // Press Cmd+K to insert a link + await pressKeyWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + expect( await page.$( '.block-editor-url-popover' ) ).not.toBeNull(); + + // Tab to the settings icon button. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + + // Expect the the escape key to dismiss the popover normally. + await page.keyboard.press( 'Escape' ); + expect( await page.$( '.block-editor-url-popover' ) ).toBeNull(); + } ); + + it( 'can be modified using the keyboard once a link has been set', async () => { + const URL = 'https://wordpress.org/gutenberg'; + + // Create a block with some text and format it as a link. + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'primary', 'K' ); + await waitForAutoFocus(); + await page.keyboard.type( URL ); + await page.keyboard.press( 'Enter' ); + + // Deselect the link text by moving the caret to the end of the line + // and the link popover should not be displayed. + await page.keyboard.press( 'End' ); + expect( await page.$( '.block-editor-url-popover' ) ).toBeNull(); + + // Move the caret back into the link text and the link popover + // should be displayed. + await page.keyboard.press( 'ArrowLeft' ); + expect( await page.$( '.block-editor-url-popover' ) ).not.toBeNull(); + + // Press Cmd+K to edit the link and the url-input should become + // focused with the value previously inserted. + await pressKeyWithModifier( 'primary', 'K' ); + await waitForAutoFocus(); + const activeElementParentClasses = await page.evaluate( () => Object.values( document.activeElement.parentElement.classList ) ); + expect( activeElementParentClasses ).toContain( 'block-editor-url-input' ); + const activeElementValue = await page.evaluate( () => document.activeElement.value ); + expect( activeElementValue ).toBe( URL ); + } ); + + it( 'adds an assertive message for screenreader users when an invalid link is set', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'primary', 'K' ); + await waitForAutoFocus(); + await page.keyboard.type( 'http://#test.com' ); + await page.keyboard.press( 'Enter' ); + const assertiveContent = await page.evaluate( () => document.querySelector( '#a11y-speak-assertive' ).textContent ); + expect( assertiveContent.trim() ).toBe( 'Warning: the link has been inserted but may have errors. Please test it.' ); + } ); + + it( 'link popover remains visible after a mouse drag event', async () => { + // Create some blocks so we have components with event handlers on the page + for ( let loop = 0; loop < 5; loop++ ) { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'This is Gutenberg' ); + } + + // Focus on first paragraph, so the link popover will appear over the subsequent ones + await page.click( '[aria-label="Block navigation"]' ); + await page.click( '.block-editor-block-navigation__item button' ); + + // Select some text + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Click on the Link Settings button + await page.click( 'button[aria-label="Link settings"]' ); + + // Move mouse over the 'open in new tab' section, then click and drag + const settings = await page.$( '.block-editor-url-popover__settings' ); + const bounds = await settings.boundingBox(); + + await page.mouse.move( bounds.x, bounds.y ); + await page.mouse.down(); + await page.mouse.move( bounds.x + ( bounds.width / 2 ), bounds.y, { steps: 10 } ); + await page.mouse.up(); + + // The link popover should still be visible + const popover = await page.$$( '.block-editor-url-popover' ); + expect( popover ).toHaveLength( 1 ); + } ); + + it( 'should contain a label when it should open in a new tab', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'This is WordPress' ); + // Select "WordPress". + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressKeyWithModifier( 'primary', 'k' ); + await waitForAutoFocus(); + await page.keyboard.type( 'w.org' ); + // Navigate to the settings toggle. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + // Open settings. + await page.keyboard.press( 'Space' ); + // Navigate to the "Open in New Tab" checkbox. + await page.keyboard.press( 'Tab' ); + // Check the checkbox. + await page.keyboard.press( 'Space' ); + // Navigate back to the input field. + await page.keyboard.press( 'Tab' ); + // Submit the form. + await page.keyboard.press( 'Enter' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + // Regression Test: This verifies that the UI is updated according to + // the expected changed values, where previously the value could have + // fallen out of sync with how the UI is displayed (specifically for + // collapsed selections). + // + // See: https://github.com/WordPress/gutenberg/pull/15573 + + // Collapse selection. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + // Edit link. + await pressKeyWithModifier( 'primary', 'k' ); + await waitForAutoFocus(); + await pressKeyWithModifier( 'primary', 'a' ); + await page.keyboard.type( 'wordpress.org' ); + // Navigate to the settings toggle. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + // Open settings. + await page.keyboard.press( 'Space' ); + // Navigate to the "Open in New Tab" checkbox. + await page.keyboard.press( 'Tab' ); + // Uncheck the checkbox. + await page.keyboard.press( 'Space' ); + // Navigate back to the input field. + await page.keyboard.press( 'Tab' ); + // Submit the form. + await page.keyboard.press( 'Enter' ); + + // Navigate back to inputs to verify appears as changed. + await pressKeyWithModifier( 'primary', 'k' ); + await waitForAutoFocus(); + const link = await page.evaluate( () => document.activeElement.value ); + expect( link ).toBe( 'http://wordpress.org' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Space' ); + await page.keyboard.press( 'Tab' ); + const isChecked = await page.evaluate( () => document.activeElement.checked ); + expect( isChecked ).toBe( false ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/manage-reusable-blocks.test.js b/gb-src/packages/e2e-tests/specs/manage-reusable-blocks.test.js new file mode 100644 index 0000000000000..37340d669f5a7 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/manage-reusable-blocks.test.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import path from 'path'; + +/** + * WordPress dependencies + */ +import { visitAdminPage } from '@wordpress/e2e-test-utils'; + +describe( 'Managing reusable blocks', () => { + /** + * Returns a Promise which resolves to the number of post list entries on + * the current page. + * + * @return {Promise} Promise resolving to number of post list entries. + */ + async function getNumberOfEntries() { + return page.evaluate( () => document.querySelectorAll( '.hentry' ).length ); + } + + beforeAll( async () => { + await visitAdminPage( 'edit.php', 'post_type=wp_block' ); + } ); + + it( 'Should import reusable blocks', async () => { + const originalEntries = await getNumberOfEntries(); + + // Import Reusable block + await page.waitForSelector( '.list-reusable-blocks__container' ); + const importButton = await page.$( '.list-reusable-blocks__container button' ); + await importButton.click(); + + // Select the file to upload + const testReusableBlockFile = path.join( __dirname, '..', 'assets', 'greeting-reusable-block.json' ); + const input = await page.$( '.list-reusable-blocks-import-form input' ); + await input.uploadFile( testReusableBlockFile ); + + // Submit the form + const button = await page.$( '.list-reusable-blocks-import-form__button' ); + await button.click(); + + // Wait for the success notice + await page.waitForSelector( '.notice-success' ); + const noticeContent = await page.$eval( '.notice-success', ( element ) => element.textContent ); + expect( noticeContent ).toEqual( 'Reusable block imported successfully!' ); + + // Refresh the page + await visitAdminPage( 'edit.php', 'post_type=wp_block' ); + + const expectedEntries = originalEntries + 1; + const actualEntries = await getNumberOfEntries(); + + expect( actualEntries ).toBe( expectedEntries ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/mentions.test.js b/gb-src/packages/e2e-tests/specs/mentions.test.js new file mode 100644 index 0000000000000..2d2f220bc4740 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/mentions.test.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + getEditedPostContent, + clickBlockAppender, +} from '@wordpress/e2e-test-utils'; + +describe( 'autocomplete mentions', () => { + beforeAll( async () => { + await createNewPost(); + } ); + + it( 'should insert mention', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'I am @a' ); + await page.waitForSelector( '.components-autocomplete__result' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '.' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/multi-block-selection.test.js b/gb-src/packages/e2e-tests/specs/multi-block-selection.test.js new file mode 100644 index 0000000000000..6bf7a62bf7789 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/multi-block-selection.test.js @@ -0,0 +1,206 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + insertBlock, + createNewPost, + pressKeyWithModifier, + pressKeyTimes, + getEditedPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'Multi-block selection', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Should select/unselect multiple blocks', async () => { + const firstBlockSelector = '[data-type="core/paragraph"]'; + const secondBlockSelector = '[data-type="core/image"]'; + const thirdBlockSelector = '[data-type="core/quote"]'; + const multiSelectedCssClass = 'is-multi-selected'; + + // Creating test blocks + await clickBlockAppender(); + await page.keyboard.type( 'First Paragraph' ); + await insertBlock( 'Image' ); + await insertBlock( 'Quote' ); + await page.keyboard.type( 'Quote Block' ); + + const blocks = [ firstBlockSelector, secondBlockSelector, thirdBlockSelector ]; + const expectMultiSelected = async ( selectors, areMultiSelected ) => { + for ( const selector of selectors ) { + const className = await page.$eval( selector, ( element ) => element.className ); + if ( areMultiSelected ) { + expect( className ).toEqual( expect.stringContaining( multiSelectedCssClass ) ); + } else { + expect( className ).not.toEqual( expect.stringContaining( multiSelectedCssClass ) ); + } + } + }; + + // Default: No selection + await expectMultiSelected( blocks, false ); + + // Multiselect via Shift + click + await page.mouse.move( 200, 300 ); + await page.click( firstBlockSelector ); + await page.keyboard.down( 'Shift' ); + await page.click( thirdBlockSelector ); + await page.keyboard.up( 'Shift' ); + + // Verify selection + await expectMultiSelected( blocks, true ); + + // Unselect + await page.click( secondBlockSelector ); + + // No selection + await expectMultiSelected( blocks, false ); + + // Multiselect via keyboard + await page.click( 'body' ); + await pressKeyWithModifier( 'primary', 'a' ); + + // Verify selection + await expectMultiSelected( blocks, true ); + + // Unselect + await page.keyboard.press( 'Escape' ); + + // No selection + await expectMultiSelected( blocks, false ); + + // Select all via double shortcut. + await page.click( firstBlockSelector ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + await expectMultiSelected( blocks, true ); + } ); + + it( 'Should select/unselect multiple blocks using Shift + Arrows', async () => { + const firstBlockSelector = '[data-type="core/paragraph"]'; + const secondBlockSelector = '[data-type="core/image"]'; + const thirdBlockSelector = '[data-type="core/quote"]'; + const multiSelectedCssClass = 'is-multi-selected'; + + // Creating test blocks + await clickBlockAppender(); + await page.keyboard.type( 'First Paragraph' ); + await insertBlock( 'Image' ); + await insertBlock( 'Quote' ); + await page.keyboard.type( 'Quote Block' ); + + const blocks = [ firstBlockSelector, secondBlockSelector, thirdBlockSelector ]; + const expectMultiSelected = async ( selectors, areMultiSelected ) => { + for ( const selector of selectors ) { + const className = await page.$eval( selector, ( element ) => element.className ); + if ( areMultiSelected ) { + expect( className ).toEqual( expect.stringContaining( multiSelectedCssClass ) ); + } else { + expect( className ).not.toEqual( expect.stringContaining( multiSelectedCssClass ) ); + } + } + }; + + // Default: No selection + await expectMultiSelected( blocks, false ); + + // Multiselect via Shift + click + await page.mouse.move( 200, 300 ); + await page.click( firstBlockSelector ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowDown' ); // Two blocks selected + await page.keyboard.press( 'ArrowDown' ); // Three blocks selected + await page.keyboard.up( 'Shift' ); + + // Verify selection + await expectMultiSelected( blocks, true ); + } ); + + it( 'should speak() number of blocks selected with multi-block selection', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'First Paragraph' ); + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Second Paragraph' ); + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Third Paragraph' ); + + // Multiselect via keyboard. + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + // TODO: It would be great to do this test by spying on `wp.a11y.speak`, + // but it's very difficult to do that because `wp.a11y` has + // DOM-dependant side-effect setup code and doesn't seem straightforward + // to mock. Instead, we check for the DOM node that `wp.a11y.speak()` + // inserts text into. + const speakTextContent = await page.$eval( '#a11y-speak-assertive', ( element ) => element.textContent ); + expect( speakTextContent.trim() ).toEqual( '3 blocks selected.' ); + } ); + + // See #14448: an incorrect buffer may trigger multi-selection too soon. + it( 'should only trigger multi-selection when at the end', async () => { + // Create a paragraph with four lines. + await clickBlockAppender(); + await page.keyboard.type( '1.' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( '2.' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( '3.' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( '4.' ); + // Create a second block. + await page.keyboard.press( 'Enter' ); + // Move to the middle of the first line. + await pressKeyTimes( 'ArrowUp', 4 ); + await page.keyboard.press( 'ArrowRight' ); + // Select mid line one to mid line four. + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + // Delete the text to see if the selection was correct. + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should use selection direction to determine vertical edge', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( '2' ); + + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + + // Should type at the end of the paragraph. + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should always expand single line selection', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '12' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowRight' ); + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + // This delete all blocks. + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should allow selecting outer edge if there is no sibling block', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + // This should replace the content. + await page.keyboard.type( '2' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/navigable-toolbar.test.js b/gb-src/packages/e2e-tests/specs/navigable-toolbar.test.js new file mode 100644 index 0000000000000..1b8efda52e1bb --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/navigable-toolbar.test.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { createNewPost, pressKeyWithModifier } from '@wordpress/e2e-test-utils'; + +describe.each( [ [ 'unified', true ], [ 'contextual', false ] ] )( + 'block toolbar (%s: %p)', + ( label, isUnifiedToolbar ) => { + beforeEach( async () => { + await createNewPost(); + + await page.evaluate( ( _isUnifiedToolbar ) => { + const { select, dispatch } = wp.data; + const isCurrentlyUnified = select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); + if ( isCurrentlyUnified !== _isUnifiedToolbar ) { + dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); + } + }, isUnifiedToolbar ); + } ); + + const isInBlockToolbar = () => page.evaluate( ( _isUnifiedToolbar ) => { + if ( _isUnifiedToolbar ) { + return !! document.activeElement + .closest( '.edit-post-header-toolbar' ) + .querySelector( '.block-editor-block-toolbar' ); + } + return !! document.activeElement.closest( '.block-editor-block-toolbar' ); + }, isUnifiedToolbar ); + + it( 'navigates in and out of toolbar by keyboard (Alt+F10, Escape)', async () => { + // Assumes new post focus starts in title. Create first new + // block by ArrowDown. + await page.keyboard.press( 'ArrowDown' ); + + // [TEMPORARY]: A new paragraph is not technically a block yet + // until starting to type within it. + await page.keyboard.type( 'Example' ); + + // Upward + await pressKeyWithModifier( 'alt', 'F10' ); + expect( await isInBlockToolbar() ).toBe( true ); + } ); + } +); + diff --git a/gb-src/packages/e2e-tests/specs/new-post-default-content.test.js b/gb-src/packages/e2e-tests/specs/new-post-default-content.test.js new file mode 100644 index 0000000000000..3fdad97d46f64 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/new-post-default-content.test.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + findSidebarPanelToggleButtonWithTitle, + getEditedPostContent, + openDocumentSettingsSidebar, +} from '@wordpress/e2e-test-utils'; + +describe( 'new editor filtered state', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-default-post-content' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-default-post-content' ); + } ); + + it( 'should respect default content', async () => { + // get the values that should have their defaults changed. + const title = await page.$eval( + '.editor-post-title__input', + ( element ) => element.innerHTML + ); + const content = await getEditedPostContent(); + + // open the sidebar, we want to see the excerpt. + await openDocumentSettingsSidebar(); + const excerptButton = await findSidebarPanelToggleButtonWithTitle( 'Excerpt' ); + if ( excerptButton ) { + await excerptButton.click( 'button' ); + } + const excerpt = await page.$eval( + '.editor-post-excerpt textarea', + ( element ) => element.innerHTML + ); + + // assert they match what the plugin set. + expect( title ).toBe( 'My default title' ); + expect( content ).toBe( 'My default content' ); + expect( excerpt ).toBe( 'My default excerpt' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/new-post.test.js b/gb-src/packages/e2e-tests/specs/new-post.test.js new file mode 100644 index 0000000000000..c4cb3fa082dca --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/new-post.test.js @@ -0,0 +1,85 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, +} from '@wordpress/e2e-test-utils'; + +describe( 'new editor state', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-post-formats-support' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-post-formats-support' ); + } ); + + it( 'should show the New Post page in Gutenberg', async () => { + expect( page.url() ).toEqual( expect.stringContaining( 'post-new.php' ) ); + // Should display the blank title. + const title = await page.$( '[placeholder="Add title"]' ); + expect( title ).not.toBeNull(); + expect( title.innerHTML ).toBeFalsy(); + // Should display the Preview button. + const postPreviewButton = await page.$( '.editor-post-preview.components-button' ); + expect( postPreviewButton ).not.toBeNull(); + // Should display the Post Formats UI. + const postFormatsUi = await page.$( '.editor-post-format' ); + expect( postFormatsUi ).not.toBeNull(); + } ); + + it( 'should have no history', async () => { + const undoButton = await page.$( '.editor-history__undo[aria-disabled="false"]' ); + const redoButton = await page.$( '.editor-history__redo[aria-disabled="false"]' ); + + expect( undoButton ).toBeNull(); + expect( redoButton ).toBeNull(); + } ); + + it( 'should focus the title if the title is empty', async () => { + const activeElementClasses = await page.evaluate( () => { + return Object.values( document.activeElement.classList ); + } ); + const activeElementTagName = await page.evaluate( () => { + return document.activeElement.tagName.toLowerCase(); + } ); + + expect( activeElementClasses ).toContain( 'editor-post-title__input' ); + expect( activeElementTagName ).toEqual( 'textarea' ); + } ); + + it( 'should not focus the title if the title exists', async () => { + // Enter a title for this post. + await page.type( '.editor-post-title__input', 'Here is the title' ); + // Save the post as a draft. + await page.click( '.editor-post-save-draft' ); + await page.waitForSelector( '.editor-post-saved-state.is-saved' ); + // Reload the browser so a post is loaded with a title. + await page.reload(); + + const activeElementClasses = await page.evaluate( () => { + return Object.values( document.activeElement.classList ); + } ); + const activeElementTagName = await page.evaluate( () => { + return document.activeElement.tagName.toLowerCase(); + } ); + + expect( activeElementClasses ).not.toContain( 'editor-post-title__input' ); + // The document `body` should be the `activeElement`, because nothing is + // focused by default when a post already has a title. + expect( activeElementTagName ).toEqual( 'body' ); + } ); + + it( 'should be saveable with sufficient initial edits', async () => { + await createNewPost( { title: 'Here is the title' } ); + + // Verify saveable by presence of the Save Draft button. + await page.$( 'button.editor-post-save-draft' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/nux.test.js b/gb-src/packages/e2e-tests/specs/nux.test.js new file mode 100644 index 0000000000000..659263cea3533 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/nux.test.js @@ -0,0 +1,180 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + clickOnMoreMenuItem, + createNewPost, + saveDraft, + toggleScreenOption, +} from '@wordpress/e2e-test-utils'; + +describe( 'New User Experience (NUX)', () => { + async function clickAllTips( page ) { + // Click through all available tips. + const tips = await getTips( page ); + const numberOfTips = tips.tipIds.length; + + for ( let i = 1; i < numberOfTips; i++ ) { + await page.click( '.nux-dot-tip .components-button.is-link' ); + } + + return { numberOfTips, tips }; + } + + async function getTips( page ) { + return await page.evaluate( () => { + return wp.data.select( 'core/nux' ).getAssociatedGuide( 'core/editor.inserter' ); + } ); + } + + async function getTipsEnabled( page ) { + return await page.evaluate( () => { + return wp.data.select( 'core/nux' ).areTipsEnabled(); + } ); + } + + beforeEach( async () => { + await createNewPost( { enableTips: true } ); + } ); + + it( 'should show tips to a first-time user', async () => { + const firstTipText = await page.$eval( '.nux-dot-tip', ( element ) => element.innerText ); + expect( firstTipText ).toContain( 'Welcome to the wonderful world of blocks!' ); + + const [ nextTipButton ] = await page.$x( "//button[contains(text(), 'See next tip')]" ); + await nextTipButton.click(); + + const secondTipText = await page.$eval( '.nux-dot-tip', ( element ) => element.innerText ); + expect( secondTipText ).toContain( 'You’ll find more settings for your page and blocks in the sidebar.' ); + } ); + + it( 'should show "Got it" once all tips have been displayed', async () => { + await clickAllTips( page ); + + // Make sure "Got it" button appears on the last tip. + const gotItButton = await page.$x( "//button[contains(text(), 'Got it')]" ); + expect( gotItButton ).toHaveLength( 1 ); + + // Click the "Got it button". + await page.click( '.nux-dot-tip .components-button.is-link' ); + + // Verify no more tips are visible on the page. + const nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 0 ); + + // Tips should not be marked as disabled, but when the user has seen all + // of the available tips, they will not appear. + const areTipsEnabled = await getTipsEnabled( page ); + expect( areTipsEnabled ).toEqual( true ); + } ); + + it( 'should hide and disable tips if "disable tips" button is clicked', async () => { + await page.click( '.nux-dot-tip__disable' ); + + // Verify no more tips are visible on the page. + let nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 0 ); + + // We should be disabling the tips so they don't appear again. + const areTipsEnabled = await getTipsEnabled( page ); + expect( areTipsEnabled ).toEqual( false ); + + // Refresh the page; tips should not show because they were disabled. + await page.reload(); + + nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 0 ); + } ); + + it( 'should enable tips when the "Tips" option is toggled on', async () => { + // Start by disabling tips. + await page.click( '.nux-dot-tip__disable' ); + + // Verify no more tips are visible on the page. + let nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 0 ); + + // Tips should be disabled in localStorage as well. + let areTipsEnabled = await getTipsEnabled( page ); + expect( areTipsEnabled ).toEqual( false ); + + // Toggle the 'Tips' option to enable. + await toggleScreenOption( 'Tips' ); + + // Tips should once again appear. + nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 1 ); + + // Tips should be enabled in localStorage as well. + areTipsEnabled = await getTipsEnabled( page ); + expect( areTipsEnabled ).toEqual( true ); + } ); + + // TODO: This test should be enabled once + // https://github.com/WordPress/gutenberg/issues/7458 is fixed. + it.skip( 'should show tips as disabled if all tips have been shown', async () => { + await clickAllTips( page ); + + // Open the "More" menu to check the "Show Tips" element. + await page.click( '.edit-post-more-menu [aria-label="More tools & options"]' ); + const showTipsButton = await page.$x( '//button[contains(text(), "Show Tips")][@aria-pressed="false"]' ); + + expect( showTipsButton ).toHaveLength( 1 ); + } ); + + // TODO: This test should be enabled once + // https://github.com/WordPress/gutenberg/issues/7458 is fixed. + it.skip( 'should reset tips if all tips have been shown and show tips was unchecked', async () => { + const { numberOfTips } = await clickAllTips( page ); + + // Click again to re-enable tips; they should appear. + await clickOnMoreMenuItem( 'Show Tips' ); + + // Open the "More" menu to check the "Show Tips" element. + await page.click( '.edit-post-more-menu [aria-label="More tools & options"]' ); + const showTipsButton = await page.$x( '//button[contains(text(), "Show Tips")][@aria-pressed="true"]' ); + + expect( showTipsButton ).toHaveLength( 1 ); + + // Tips should re-appear on the page. + const nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 1 ); + + // Tips should be enabled again. + const areTipsEnabled = await getTipsEnabled( page ); + expect( areTipsEnabled ).toEqual( true ); + + // Dismissed tips should be reset and ready to be shown again. + const resetTips = await getTips( page ); + const newNumberOfTips = resetTips.tipIds.length; + expect( newNumberOfTips ).toHaveLength( numberOfTips ); + } ); + + // TODO: This test should be enabled once + // https://github.com/WordPress/gutenberg/issues/7753 is fixed. + // See: https://github.com/WordPress/gutenberg/issues/7753#issuecomment-403952816 + it.skip( 'should show tips if "Show tips" was disabled on a draft and then enabled', async () => { + // Click the "Show tips" button (enabled by default) to disable tips. + await clickOnMoreMenuItem( 'Show Tips' ); + + // Let's type something so there's content in this post. + await page.click( '.editor-post-title__input' ); + await page.keyboard.type( 'Post title' ); + await clickBlockAppender(); + await page.keyboard.type( 'Post content goes here.' ); + await saveDraft(); + + // Refresh the page; tips should be disabled. + await page.reload(); + let nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 0 ); + + // Clicking should re-enable tips. + await clickOnMoreMenuItem( 'Show Tips' ); + + // Tips should re-appear on the page. + nuxTipElements = await page.$$( '.nux-dot-tip' ); + expect( nuxTipElements ).toHaveLength( 1 ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/performance.test.js b/gb-src/packages/e2e-tests/specs/performance.test.js new file mode 100644 index 0000000000000..174bf2c6ab130 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/performance.test.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; + +/** + * WordPress dependencies + */ +import { + createNewPost, + saveDraft, + insertBlock, + disableNavigationMode, +} from '@wordpress/e2e-test-utils'; + +function readFile( filePath ) { + return existsSync( filePath ) ? readFileSync( filePath, 'utf8' ).trim() : ''; +} + +describe( 'Performance', () => { + it( '1000 paragraphs', async () => { + const html = readFile( join( __dirname, '../assets/large-post.html' ) ); + + await createNewPost(); + await page.evaluate( ( _html ) => { + const { parse } = window.wp.blocks; + const { dispatch } = window.wp.data; + const blocks = parse( _html ); + + blocks.forEach( ( block ) => { + if ( block.name === 'core/image' ) { + delete block.attributes.id; + delete block.attributes.url; + } + } ); + + dispatch( 'core/block-editor' ).resetBlocks( blocks ); + }, html ); + await saveDraft(); + + const results = { + load: [], + domcontentloaded: [], + type: [], + }; + + let i = 1; + let startTime; + + await page.on( 'load', () => results.load.push( new Date() - startTime ) ); + await page.on( 'domcontentloaded', () => results.domcontentloaded.push( new Date() - startTime ) ); + + while ( i-- ) { + startTime = new Date(); + await page.reload( { waitUntil: [ 'domcontentloaded', 'load' ] } ); + await disableNavigationMode(); + } + + await insertBlock( 'Paragraph' ); + + i = 200; + + while ( i-- ) { + startTime = new Date(); + await page.keyboard.type( 'x' ); + results.type.push( new Date() - startTime ); + } + + writeFileSync( __dirname + '/results.json', JSON.stringify( results, null, 2 ) ); + + expect( true ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/align-hook.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/align-hook.test.js.snap new file mode 100644 index 0000000000000..e0075595e01b0 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/align-hook.test.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Align Hook Works As Expected Block with align array Correctly applies the selected alignment and correctly removes the alignment 1`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with align array Correctly applies the selected alignment and correctly removes the alignment 2`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with align true Correctly applies the selected alignment and correctly removes the alignment 1`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with align true Correctly applies the selected alignment and correctly removes the alignment 2`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with default align Correctly applies the selected alignment and correctly removes the alignment 1`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with default align Correctly applies the selected alignment and correctly removes the alignment 2`] = ` +" +
Test Align Hook
+" +`; + +exports[`Align Hook Works As Expected Block with no alignment set Does not save any alignment related attribute or class 1`] = ` +" +
Test Align Hook
+" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/container-blocks.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/container-blocks.test.js.snap new file mode 100644 index 0000000000000..3e2a04aae65dd --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/container-blocks.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Container block without paragraph support ensures we can use the alternative block appender properly 1`] = ` +" + +
\\"\\"/
+ +" +`; + +exports[`InnerBlocks Template Sync Ensure inner block writing flow works as expected without additional paragraphs added 1`] = ` +" + +

Test Paragraph

+ +" +`; + +exports[`InnerBlocks Template Sync Ensures blocks without locking are kept intact even if they do not match the template 1`] = ` +" + +

Content…

+ + + +

added paragraph

+ +" +`; + +exports[`InnerBlocks Template Sync Removes blocks that are not expected by the template if a lock all exists 1`] = ` +" + +

Content…

+ +" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap new file mode 100644 index 0000000000000..24166c89f310b --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/cpt-locking.test.js.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`cpt locking template_lock all should not error when deleting the cotents of a paragraph 1`] = ` +" +
\\"\\"/
+ + + +

+ + + +

+" +`; + +exports[`cpt locking template_lock false should allow blocks to be inserted 1`] = ` +" +
\\"\\"/
+ + + +

+ + + +

+ + + +
  • List content
+" +`; + +exports[`cpt locking template_lock false should allow blocks to be moved 1`] = ` +" +

p1

+ + + +
\\"\\"/
+ + + +

+" +`; + +exports[`cpt locking template_lock false should allow blocks to be removed 1`] = ` +" +
\\"\\"/
+ + + +

+" +`; + +exports[`cpt locking template_lock insert should allow blocks to be moved 1`] = ` +" +

p1

+ + + +
\\"\\"/
+ + + +

+" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/deprecated-node-matcher.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/deprecated-node-matcher.test.js.snap new file mode 100644 index 0000000000000..d36c990c04c37 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/deprecated-node-matcher.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Deprecated Node Matcher should insert block with children source 1`] = ` +" +

test
a

+" +`; + +exports[`Deprecated Node Matcher should insert block with node source 1`] = ` +" +

test

+" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/format-api.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/format-api.test.js.snap new file mode 100644 index 0000000000000..e5a006328d097 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/format-api.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Using Format API Clicking the control wraps the selected text properly with HTML code 1`] = ` +" +

First paragraph

+" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/hooks-api.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/hooks-api.test.js.snap new file mode 100644 index 0000000000000..67fa4a4a04d0f --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/hooks-api.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Using Hooks API Pressing reset block button resets the block 1`] = `""`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/meta-attribute-block.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/meta-attribute-block.test.js.snap new file mode 100644 index 0000000000000..076a8ebbaac6e --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/meta-attribute-block.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Block with a meta attribute Should persist the meta attribute properly 1`] = `""`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/plugins-api.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/plugins-api.test.js.snap new file mode 100644 index 0000000000000..c2baa4270d086 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/plugins-api.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Using Plugins API Document Setting Custom Panel Should render a custom panel inside Document Setting sidebar 1`] = `"My Custom Panel"`; + +exports[`Using Plugins API Sidebar Medium screen Should open plugins sidebar using More Menu item and render content 1`] = `"
Sidebar title plugin
"`; + +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
Sidebar title plugin
"`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/templates.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/templates.test.js.snap new file mode 100644 index 0000000000000..f089390857781 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/templates.test.js.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`templates Using a CPT with a predefined template Should add a custom post types with a predefined template 1`] = ` +" +
\\"\\"/
+ + + +

+ + + +

+ + + +
+
+
\\"\\"/
+
+ + + +
+

+
+
+" +`; + +exports[`templates Using a CPT with a predefined template Should respect user edits to not re-apply template after save (full delete) 1`] = `""`; + +exports[`templates Using a CPT with a predefined template Should respect user edits to not re-apply template after save (single block removal) 1`] = ` +" +

+ + + +

+ + + +
+
+
\\"\\"/
+
+ + + +
+

+
+
+" +`; + +exports[`templates With default post format assigned should not populate edited post with default block for format 1`] = `""`; + +exports[`templates With default post format assigned should not populate new page with default block for format 1`] = `""`; + +exports[`templates With default post format assigned should populate new post with default block for format 1`] = ` +" +
\\"\\"/
+" +`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/wp-editor-meta-box.test.js.snap b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/wp-editor-meta-box.test.js.snap new file mode 100644 index 0000000000000..485862873b648 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/__snapshots__/wp-editor-meta-box.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WP Editor Meta Boxes Should save the changes 1`] = `"

Typing in a metabox

"`; diff --git a/gb-src/packages/e2e-tests/specs/plugins/align-hook.test.js b/gb-src/packages/e2e-tests/specs/plugins/align-hook.test.js new file mode 100644 index 0000000000000..4735560b8fb81 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/align-hook.test.js @@ -0,0 +1,218 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getAllBlocks, + getEditedPostContent, + insertBlock, + selectBlockByClientId, + setPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'Align Hook Works As Expected', () => { + const CHANGE_ALIGNMENT_BUTTON_SELECTOR = '.block-editor-block-toolbar .components-dropdown-menu__toggle[aria-label="Change alignment"]'; + + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-align-hook' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-align-hook' ); + } ); + + const getAlignmentToolbarLabels = async () => { + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + + const buttonLabels = await page.evaluate( () => { + return Array.from( + document.querySelectorAll( + '.components-dropdown-menu__menu button' + ) + ).map( + ( button ) => { + return button.innerText; + } + ); + } ); + return buttonLabels; + }; + + const createShowsTheExpectedButtonsTest = ( blockName, buttonLabels ) => { + it( 'Shows the expected buttons on the alignment toolbar', + async () => { + await insertBlock( blockName ); + + expect( + await getAlignmentToolbarLabels() + ).toEqual( buttonLabels ); + } ); + }; + + const createDoesNotApplyAlignmentByDefaultTest = ( blockName ) => { + it( 'Does not apply any alignment by default', async () => { + await insertBlock( blockName ); + + // verify no alignment button is in pressed state + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + const pressedButtons = await page.$$( '.components-dropdown-menu__menu button.is-active' ); + expect( pressedButtons ).toHaveLength( 0 ); + } ); + }; + + const verifyMarkupIsValid = async ( htmlMarkup ) => { + await setPostContent( htmlMarkup ); + const blocks = await getAllBlocks(); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ].isValid ).toBeTruthy(); + }; + + const createCorrectlyAppliesAndRemovesAlignmentTest = ( blockName, alignment ) => { + it( 'Correctly applies the selected alignment and correctly removes the alignment', + async () => { + const BUTTON_SELECTOR = `.components-dropdown-menu__menu button svg.dashicons-align-${ alignment }`; + const BUTTON_PRESSED_SELECTOR = '.components-dropdown-menu__menu button.is-active'; + // set the specified alignment. + await insertBlock( blockName ); + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + await page.click( BUTTON_SELECTOR ); + + // verify the button of the specified alignment is pressed. + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + let pressedButtons = await page.$$( BUTTON_PRESSED_SELECTOR ); + expect( pressedButtons ).toHaveLength( 1 ); + + let htmlMarkup = await getEditedPostContent(); + + // verify the markup of the selected alignment was generated. + expect( htmlMarkup ).toMatchSnapshot(); + + // verify the markup can be correctly parsed + await verifyMarkupIsValid( htmlMarkup ); + + await selectBlockByClientId( + ( await getAllBlocks() )[ 0 ].clientId + ); + + // remove the alignment. + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + await page.click( BUTTON_SELECTOR ); + + // verify no alignment button is in pressed state. + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + pressedButtons = await page.$$( BUTTON_PRESSED_SELECTOR ); + expect( pressedButtons ).toHaveLength( 0 ); + + // verify alignment markup was removed from the block. + htmlMarkup = await getEditedPostContent(); + expect( htmlMarkup ).toMatchSnapshot(); + + // verify the markup when no alignment is set is valid + await verifyMarkupIsValid( htmlMarkup ); + + await selectBlockByClientId( + ( await getAllBlocks() )[ 0 ].clientId + ); + + // verify no alignment button is in pressed state after parsing the block. + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + pressedButtons = await page.$$( BUTTON_PRESSED_SELECTOR ); + expect( pressedButtons ).toHaveLength( 0 ); + } + ); + }; + + describe( 'Block with no alignment set', () => { + const BLOCK_NAME = 'Test No Alignment Set'; + it( 'Shows no alignment buttons on the alignment toolbar', + async () => { + await insertBlock( BLOCK_NAME ); + const changeAlignmentButton = await page.$( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + expect( changeAlignmentButton ).toBe( null ); + } + ); + + it( 'Does not save any alignment related attribute or class', + async () => { + await insertBlock( BLOCK_NAME ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } + ); + } ); + + describe( 'Block with align true', () => { + const BLOCK_NAME = 'Test Align True'; + + createShowsTheExpectedButtonsTest( BLOCK_NAME, [ + 'Align Left', + 'Align Center', + 'Align Right', + 'Wide Width', + 'Full Width', + ] ); + + createDoesNotApplyAlignmentByDefaultTest( BLOCK_NAME ); + + createCorrectlyAppliesAndRemovesAlignmentTest( BLOCK_NAME, 'right' ); + } ); + + describe( 'Block with align array', () => { + const BLOCK_NAME = 'Test Align Array'; + + createShowsTheExpectedButtonsTest( BLOCK_NAME, [ + 'Align Left', + 'Align Center', + ] ); + + createDoesNotApplyAlignmentByDefaultTest( BLOCK_NAME ); + + createCorrectlyAppliesAndRemovesAlignmentTest( BLOCK_NAME, 'center' ); + } ); + + describe( 'Block with default align', () => { + const BLOCK_NAME = 'Test Default Align'; + const SELECTED_ALIGNMENT_CONTROL_SELECTOR = '//div[contains(@class, "components-dropdown-menu__menu")]//button[contains(@class, "is-active")][text()="Align Right"]'; + createShowsTheExpectedButtonsTest( BLOCK_NAME, [ + 'Align Left', + 'Align Center', + 'Align Right', + 'Wide Width', + 'Full Width', + ] ); + + it( 'Applies the selected alignment by default', async () => { + await insertBlock( BLOCK_NAME ); + // verify the correct alignment button is pressed + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + const selectedAlignmentControls = await page.$x( SELECTED_ALIGNMENT_CONTROL_SELECTOR ); + expect( selectedAlignmentControls ).toHaveLength( 1 ); + } ); + + it( 'The default markup does not contain the alignment attribute but contains the alignment class', + async () => { + await insertBlock( BLOCK_NAME ); + const markup = await getEditedPostContent(); + expect( markup ).not.toContain( '"align":"right"' ); + expect( markup ).toContain( 'alignright' ); + } + ); + + it( 'Can remove the default alignment and the align attribute equals none but alignnone class is not applied', async () => { + await insertBlock( BLOCK_NAME ); + // remove the alignment. + await page.click( CHANGE_ALIGNMENT_BUTTON_SELECTOR ); + const [ selectedAlignmentControl ] = await page.$x( SELECTED_ALIGNMENT_CONTROL_SELECTOR ); + await selectedAlignmentControl.click(); + const markup = await getEditedPostContent(); + expect( markup ).toContain( '"align":""' ); + } ); + + createCorrectlyAppliesAndRemovesAlignmentTest( BLOCK_NAME, 'center' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/allowed-blocks.test.js b/gb-src/packages/e2e-tests/specs/plugins/allowed-blocks.test.js new file mode 100644 index 0000000000000..bed48d6e93ff2 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/allowed-blocks.test.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickOnMoreMenuItem, + createNewPost, + deactivatePlugin, + searchForBlock, +} from '@wordpress/e2e-test-utils'; + +describe( 'Allowed Blocks Filter', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-allowed-blocks' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-allowed-blocks' ); + } ); + + it( 'should restrict the allowed blocks in the inserter', async () => { + // The paragraph block is available. + await searchForBlock( 'Paragraph' ); + const paragraphBlockButton = ( await page.$x( + `//button//span[contains(text(), 'Paragraph')]` + ) )[ 0 ]; + expect( paragraphBlockButton ).not.toBeNull(); + await paragraphBlockButton.click(); + + // The gallery block is not available. + await searchForBlock( 'Gallery' ); + + const galleryBlockButton = ( await page.$x( + `//button//span[contains(text(), 'Gallery')]` + ) )[ 0 ]; + expect( galleryBlockButton ).toBeUndefined(); + } ); + + it( 'should remove not allowed blocks from the block manager', async () => { + await clickOnMoreMenuItem( 'Block Manager' ); + + const BLOCK_LABEL_SELECTOR = '.edit-post-manage-blocks-modal__checklist-item .components-checkbox-control__label'; + await page.waitForSelector( BLOCK_LABEL_SELECTOR ); + const blocks = await page.evaluate( + ( selector ) => { + return Array.from( document.querySelectorAll( selector ) ).map( + ( element ) => ( ( element.innerText || '' ).trim() ) + ).sort(); + }, + BLOCK_LABEL_SELECTOR + ); + expect( blocks ).toEqual( [ + 'Image', + 'Paragraph', + ] ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/annotations.test.js b/gb-src/packages/e2e-tests/specs/plugins/annotations.test.js new file mode 100644 index 0000000000000..5bab673a41d80 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/annotations.test.js @@ -0,0 +1,178 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockToolbarButton, + clickOnMoreMenuItem, + createNewPost, + deactivatePlugin, +} from '@wordpress/e2e-test-utils'; + +const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { + await clickBlockToolbarButton( 'More options' ); + const itemButton = ( await page.$x( `//*[contains(@class, "block-editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; + await itemButton.click(); +}; + +const ANNOTATIONS_SELECTOR = '.annotation-text-e2e-tests'; + +describe( 'Using Plugins API', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + /** + * Annotates the text in the first block from start to end. + * + * @param {number} start Position to start the annotation. + * @param {number} end Position to end the annotation. + * + * @return {void} + */ + async function annotateFirstBlock( start, end ) { + await page.focus( '#annotations-tests-range-start' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( start + '' ); + await page.focus( '#annotations-tests-range-end' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( end + '' ); + + // Click add annotation button. + const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Add annotation')]" ) )[ 0 ]; + await addAnnotationButton.click(); + } + + /** + * Presses the button that removes all annotations. + * + * @return {void} + */ + async function removeAnnotations() { + // Click remove annotations button. + const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Remove annotations')]" ) )[ 0 ]; + await addAnnotationButton.click(); + } + + /** + * Returns the inner text of the first text annotation on the page. + * + * @return {Promise} The annotated text. + */ + async function getAnnotatedText() { + const annotations = await page.$$( ANNOTATIONS_SELECTOR ); + + const annotation = annotations[ 0 ]; + + return await page.evaluate( ( el ) => el.innerText, annotation ); + } + + /** + * Returns the inner HTML of the first RichText in the page. + * + * @return {Promise} Inner HTML. + */ + async function getRichTextInnerHTML() { + const htmlContent = await page.$$( '*[contenteditable]' ); + return await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + } + + describe( 'Annotations', () => { + it( 'Allows a block to be annotated', async () => { + await page.keyboard.type( 'Title' + '\n' + 'Paragraph to annotate' ); + + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + let annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 0 ); + + await annotateFirstBlock( 9, 13 ); + + annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 1 ); + + const text = await getAnnotatedText(); + expect( text ).toBe( ' to ' ); + + await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); + + const htmlContent = await page.$$( '.block-editor-block-list__block-html-textarea' ); + const html = await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + + // There should be no tags in the raw content. + expect( html ).toBe( '<p>Paragraph to annotate</p>' ); + } ); + + it( 'Keeps the cursor in the same location when applying annotation', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + // The selection should still be at the end, so test that by typing: + await page.keyboard.type( 'D' ); + + await removeAnnotations(); + const htmlContent = await page.$$( '*[contenteditable]' ); + const html = await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + + expect( html ).toBe( 'ABCD' ); + } ); + + it( 'Moves when typing before it', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Put an 1 after the A, it should not be annotated. + await page.keyboard.type( '1' ); + + const annotatedText = await getAnnotatedText(); + expect( annotatedText ).toBe( 'B' ); + + await removeAnnotations(); + const blockText = await getRichTextInnerHTML(); + expect( blockText ).toBe( 'A1BC' ); + } ); + + it( 'Grows when typing inside it', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Put an 1 after the A, it should not be annotated. + await page.keyboard.type( '2' ); + + const annotatedText = await getAnnotatedText(); + expect( annotatedText ).toBe( 'B2' ); + + await removeAnnotations(); + const blockText = await getRichTextInnerHTML(); + expect( blockText ).toBe( 'AB2C' ); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/block-icons.test.js b/gb-src/packages/e2e-tests/specs/plugins/block-icons.test.js new file mode 100644 index 0000000000000..3035a6ddf1aea --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/block-icons.test.js @@ -0,0 +1,146 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + insertBlock, + pressKeyWithModifier, + searchForBlock, +} from '@wordpress/e2e-test-utils'; + +const INSERTER_BUTTON_SELECTOR = '.components-popover__content .block-editor-block-types-list__item'; +const INSERTER_ICON_WRAPPER_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .block-editor-block-types-list__item-icon`; +const INSERTER_ICON_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .block-editor-block-icon`; +const INSPECTOR_ICON_SELECTOR = '.edit-post-sidebar .block-editor-block-icon'; + +async function getInnerHTML( selector ) { + return await page.$eval( selector, ( element ) => element.innerHTML ); +} + +async function getBackgroundColor( selector ) { + return await page.$eval( selector, ( element ) => { + return window.getComputedStyle( element ).backgroundColor; + } ); +} + +async function getColor( selector ) { + return await page.$eval( selector, ( element ) => { + return window.getComputedStyle( element ).color; + } ); +} + +async function getFirstInserterIcon() { + return await getInnerHTML( INSERTER_ICON_SELECTOR ); +} + +async function selectFirstBlock() { + await pressKeyWithModifier( 'access', 'o' ); + const navButtons = await page.$$( '.block-editor-block-navigation__item-button' ); + await navButtons[ 0 ].click(); +} + +describe( 'Correctly Renders Block Icons on Inserter and Inspector', () => { + const dashIconRegex = /.*?<\/svg>/; + const circleString = ''; + const svgIcon = new RegExp( `${ circleString }` ); + + const validateSvgIcon = ( iconHtml ) => { + expect( iconHtml ).toMatch( svgIcon ); + }; + + const validateDashIcon = ( iconHtml ) => { + expect( iconHtml ).toMatch( dashIconRegex ); + }; + + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-block-icons' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-block-icons' ); + } ); + + function testIconsOfBlock( blockName, blockTitle, validateIcon ) { + it( 'Renders correctly the icon in the inserter', async () => { + await searchForBlock( blockTitle ); + validateIcon( await getFirstInserterIcon() ); + } ); + + it( 'Can insert the block', async () => { + await insertBlock( blockTitle ); + expect( + await getInnerHTML( `[data-type="${ blockName }"] [data-type="core/paragraph"] p` ) + ).toEqual( blockTitle ); + } ); + + it( 'Renders correctly the icon on the inspector', async () => { + await insertBlock( blockTitle ); + await selectFirstBlock(); + validateIcon( await getInnerHTML( INSPECTOR_ICON_SELECTOR ) ); + } ); + } + + describe( 'Block with svg icon', () => { + const blockName = 'test/test-single-svg-icon'; + const blockTitle = 'TestSimpleSvgIcon'; + testIconsOfBlock( blockName, blockTitle, validateSvgIcon ); + } ); + + describe( 'Block with dash icon', () => { + const blockName = 'test/test-dash-icon'; + const blockTitle = 'TestDashIcon'; + testIconsOfBlock( blockName, blockTitle, validateDashIcon ); + } ); + + describe( 'Block with function icon', () => { + const blockName = 'test/test-function-icon'; + const blockTitle = 'TestFunctionIcon'; + testIconsOfBlock( blockName, blockTitle, validateSvgIcon ); + } ); + + describe( 'Block with dash icon and background and foreground colors', () => { + const blockTitle = 'TestDashIconColors'; + it( 'Renders the icon in the inserter with the correct colors', async () => { + await searchForBlock( blockTitle ); + validateDashIcon( await getFirstInserterIcon() ); + expect( await getBackgroundColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( 'rgb(1, 0, 0)' ); + expect( await getColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( 'rgb(254, 0, 0)' ); + } ); + + it( 'Renders the icon in the inspector with the correct colors', async () => { + await insertBlock( blockTitle ); + await selectFirstBlock(); + validateDashIcon( + await getInnerHTML( INSPECTOR_ICON_SELECTOR ) + ); + expect( await getBackgroundColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( 'rgb(1, 0, 0)' ); + expect( await getColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( 'rgb(254, 0, 0)' ); + } ); + } ); + + describe( 'Block with svg icon and background color', () => { + const blockTitle = 'TestSvgIconBackground'; + it( 'Renders the icon in the inserter with the correct background color and an automatically compute readable foreground color', async () => { + await searchForBlock( blockTitle ); + validateSvgIcon( await getFirstInserterIcon() ); + expect( await getBackgroundColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( 'rgb(1, 0, 0)' ); + expect( await getColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( 'rgb(248, 249, 249)' ); + } ); + + it( 'Renders correctly the icon on the inspector', async () => { + await insertBlock( blockTitle ); + await selectFirstBlock(); + validateSvgIcon( + await getInnerHTML( INSPECTOR_ICON_SELECTOR ) + ); + expect( await getBackgroundColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( 'rgb(1, 0, 0)' ); + expect( await getColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( 'rgb(248, 249, 249)' ); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/container-blocks.test.js b/gb-src/packages/e2e-tests/specs/plugins/container-blocks.test.js new file mode 100644 index 0000000000000..4afa4273f373f --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/container-blocks.test.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, + switchEditorModeTo, +} from '@wordpress/e2e-test-utils'; + +describe( 'InnerBlocks Template Sync', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-innerblocks-templates' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-innerblocks-templates' ); + } ); + + const insertBlockAndAddParagraphInside = async ( blockName, blockSlug ) => { + const paragraphToAdd = ` + +

added paragraph

+ + `; + await insertBlock( blockName ); + await switchEditorModeTo( 'Code' ); + await page.$eval( '.editor-post-text-editor', ( element, _paragraph, _blockSlug ) => { + const blockDelimiter = ``; + element.value = element.value.replace( blockDelimiter, `${ _paragraph }${ blockDelimiter }` ); + }, paragraphToAdd, blockSlug ); + // Press "Enter" inside the Code Editor to fire the `onChange` event for the new value. + await page.click( '.editor-post-text-editor' ); + await page.keyboard.press( 'Enter' ); + await switchEditorModeTo( 'Visual' ); + }; + + it( 'Ensures blocks without locking are kept intact even if they do not match the template ', async () => { + await insertBlockAndAddParagraphInside( 'Test Inner Blocks no locking', 'test/test-inner-blocks-no-locking' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'Removes blocks that are not expected by the template if a lock all exists ', async () => { + await insertBlockAndAddParagraphInside( 'Test InnerBlocks locking all', 'test/test-inner-blocks-locking-all' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'Ensure inner block writing flow works as expected without additional paragraphs added', async () => { + const TEST_BLOCK_NAME = 'Test Inner Blocks Paragraph Placeholder'; + + await insertBlock( TEST_BLOCK_NAME ); + await page.keyboard.type( 'Test Paragraph' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); + +describe( 'Container block without paragraph support', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-container-block-without-paragraph' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-container-block-without-paragraph' ); + } ); + + it( 'ensures we can use the alternative block appender properly', async () => { + await insertBlock( 'Container without paragraph' ); + + // Open the specific appender used when there's no paragraph support. + await page.click( '.block-editor-inner-blocks .block-list-appender .block-list-appender__toggle' ); + + // Insert an image block. + const insertButton = ( await page.$x( + `//button//span[contains(text(), 'Image')]` + ) )[ 0 ]; + await insertButton.click(); + + // Check the inserted content. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/cpt-locking.test.js b/gb-src/packages/e2e-tests/specs/plugins/cpt-locking.test.js new file mode 100644 index 0000000000000..74f8f81bf67e3 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/cpt-locking.test.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockToolbarButton, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, + pressKeyTimes, + setPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'cpt locking', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-cpt-locking' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-cpt-locking' ); + } ); + + const shouldRemoveTheInserter = async () => { + expect( + await page.$( '.edit-post-header [aria-label="Add block"]' ) + ).toBeNull(); + }; + + const shouldNotAllowBlocksToBeRemoved = async () => { + await page.type( '.editor-rich-text__editable.wp-block-paragraph', 'p1' ); + await clickBlockToolbarButton( 'More options' ); + expect( + await page.$x( '//button[contains(text(), "Remove Block")]' ) + ).toHaveLength( 0 ); + }; + + const shouldAllowBlocksToBeMoved = async () => { + await page.click( '.editor-rich-text__editable.wp-block-paragraph' ); + expect( + await page.$( 'button[aria-label="Move up"]' ) + ).not.toBeNull(); + await page.click( 'button[aria-label="Move up"]' ); + await page.type( '.editor-rich-text__editable.wp-block-paragraph', 'p1' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + }; + + describe( 'template_lock all', () => { + beforeEach( async () => { + await createNewPost( { postType: 'locked-all-post' } ); + } ); + + it( 'should remove the inserter', shouldRemoveTheInserter ); + + it( 'should not allow blocks to be removed', shouldNotAllowBlocksToBeRemoved ); + + it( 'should not allow blocks to be moved', async () => { + await page.click( '.editor-rich-text__editable.wp-block-paragraph' ); + expect( + await page.$( 'button[aria-label="Move up"]' ) + ).toBeNull(); + } ); + + it( 'should not error when deleting the cotents of a paragraph', async () => { + await page.click( '.block-editor-block-list__block[data-type="core/paragraph"] p' ); + const textToType = 'Paragraph'; + await page.keyboard.type( 'Paragraph' ); + await pressKeyTimes( 'Backspace', textToType.length + 1 ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should show invalid template notice if the blocks do not match the templte', async () => { + const content = await getEditedPostContent(); + const [ , contentWithoutImage ] = content.split( '' ); + await setPostContent( contentWithoutImage ); + const VALIDATION_PARAGRAPH_SELECTOR = '.editor-template-validation-notice .components-notice__content p'; + await page.waitForSelector( VALIDATION_PARAGRAPH_SELECTOR ); + expect( + await page.evaluate( + ( element ) => element.textContent, + await page.$( VALIDATION_PARAGRAPH_SELECTOR ) + ) + ).toEqual( 'The content of your post doesn’t match the template assigned to your post type.' ); + } ); + } ); + + describe( 'template_lock insert', () => { + beforeEach( async () => { + await createNewPost( { postType: 'locked-insert-post' } ); + } ); + + it( 'should remove the inserter', shouldRemoveTheInserter ); + + it( 'should not allow blocks to be removed', shouldNotAllowBlocksToBeRemoved ); + + it( 'should allow blocks to be moved', shouldAllowBlocksToBeMoved ); + } ); + + describe( 'template_lock false', () => { + beforeEach( async () => { + await createNewPost( { postType: 'not-locked-post' } ); + } ); + + it( 'should allow blocks to be inserted', async () => { + expect( + await page.$( '.edit-post-header [aria-label="Add block"]' ) + ).not.toBeNull(); + await insertBlock( 'List' ); + await page.keyboard.type( 'List content' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should allow blocks to be removed', async () => { + await page.type( '.editor-rich-text__editable.wp-block-paragraph', 'p1' ); + await clickBlockToolbarButton( 'More options' ); + const [ removeBlock ] = await page.$x( '//button[contains(text(), "Remove Block")]' ); + await removeBlock.click(); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should allow blocks to be moved', shouldAllowBlocksToBeMoved ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/custom-taxonomies.test.js b/gb-src/packages/e2e-tests/specs/plugins/custom-taxonomies.test.js new file mode 100644 index 0000000000000..fccba18eafae1 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/custom-taxonomies.test.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, findSidebarPanelWithTitle, openDocumentSettingsSidebar, +} from '@wordpress/e2e-test-utils'; + +describe( 'Custom Taxonomies labels are used', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-custom-taxonomies' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-custom-taxonomies' ); + } ); + + it( 'Ensures the custom taxonomy labels are respected', async () => { + // Open the Setting sidebar. + await openDocumentSettingsSidebar(); + + const openButton = await findSidebarPanelWithTitle( 'Model' ); + expect( openButton ).not.toBeFalsy(); + + // Get the classes from the panel + const buttonClassName = await ( await openButton.getProperty( 'className' ) ).jsonValue(); + + // Open the panel if needed. + if ( -1 === buttonClassName.indexOf( 'is-opened' ) ) { + await openButton.click(); + } + + // Check the add new button + const labelNew = await page.$x( "//label[@class='components-form-token-field__label' and contains(text(), 'Add New Model')]" ); + expect( labelNew ).not.toBeFalsy(); + + // Fill with one entry + await page.type( 'input.components-form-token-field__input', 'Model 1' ); + await page.keyboard.press( 'Enter' ); + + // Check the "Remove Model" + const value = await page.$x( "//div[@class='components-form-token-field__input-container']//span//button[@aria-label='Remove Model']" ); + expect( value ).not.toBeFalsy(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js b/gb-src/packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js new file mode 100644 index 0000000000000..21b587a00c28b --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +describe( 'Deprecated Node Matcher', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-deprecated-node-matcher' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-deprecated-node-matcher' ); + } ); + + it( 'should insert block with node source', async () => { + await insertBlock( 'Deprecated Node Matcher' ); + await page.keyboard.type( 'test' ); + await page.keyboard.press( 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert block with children source', async () => { + await insertBlock( 'Deprecated Children Matcher' ); + await page.keyboard.type( 'test' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'b' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/format-api.test.js b/gb-src/packages/e2e-tests/specs/plugins/format-api.test.js new file mode 100644 index 0000000000000..f04fc8834c7cd --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/format-api.test.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockAppender, + clickBlockToolbarButton, + createNewPost, + deactivatePlugin, + getEditedPostContent, + pressKeyWithModifier, + clickButton, +} from '@wordpress/e2e-test-utils'; + +describe( 'Using Format API', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-format-api' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-format-api' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Clicking the control wraps the selected text properly with HTML code', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'First paragraph' ); + await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); + await clickBlockToolbarButton( 'More rich text controls' ); + await clickButton( 'Custom Link' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/hooks-api.test.js b/gb-src/packages/e2e-tests/specs/plugins/hooks-api.test.js new file mode 100644 index 0000000000000..4641c534493b3 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/hooks-api.test.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockAppender, + createNewPost, + deactivatePlugin, + getEditedPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'Using Hooks API', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-hooks-api' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-hooks-api' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Should contain a reset block button on the sidebar', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'First paragraph' ); + expect( await page.$( '.edit-post-sidebar .e2e-reset-block-button' ) ).not.toBeNull(); + } ); + + it( 'Pressing reset block button resets the block', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'First paragraph' ); + const paragraphContent = await page.$eval( 'div[data-type="core/paragraph"] p', ( element ) => element.textContent ); + expect( paragraphContent ).toEqual( 'First paragraph' ); + await page.click( '.edit-post-sidebar .e2e-reset-block-button' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js b/gb-src/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js new file mode 100644 index 0000000000000..c0a2055ade762 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js @@ -0,0 +1,88 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getAllBlockInserterItemTitles, + insertBlock, + openAllBlockInserterCategories, + openGlobalBlockInserter, +} from '@wordpress/e2e-test-utils'; + +describe( 'Allowed Blocks Setting on InnerBlocks ', () => { + const paragraphSelector = '.block-editor-rich-text__editable.wp-block-paragraph'; + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-innerblocks-allowed-blocks' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-innerblocks-allowed-blocks' ); + } ); + + it( 'allows all blocks if the allowed blocks setting was not set', async () => { + const parentBlockSelector = '[data-type="test/allowed-blocks-unset"]'; + const childParagraphSelector = `${ parentBlockSelector } ${ paragraphSelector }`; + await insertBlock( 'Allowed Blocks Unset' ); + await page.waitForSelector( childParagraphSelector ); + await page.click( childParagraphSelector ); + await openGlobalBlockInserter(); + await openAllBlockInserterCategories(); + expect( + ( await getAllBlockInserterItemTitles() ).length + ).toBeGreaterThan( 20 ); + } ); + + it( 'allows the blocks if the allowed blocks setting was set', async () => { + const parentBlockSelector = '[data-type="test/allowed-blocks-set"]'; + const childParagraphSelector = `${ parentBlockSelector } ${ paragraphSelector }`; + await insertBlock( 'Allowed Blocks Set' ); + await page.waitForSelector( childParagraphSelector ); + await page.click( childParagraphSelector ); + await openGlobalBlockInserter(); + await openAllBlockInserterCategories(); + expect( + await getAllBlockInserterItemTitles() + ).toEqual( [ + 'Button', + 'Gallery', + 'List', + 'Media & Text', + 'Quote', + ] ); + } ); + + it( 'correctly applies dynamic allowed blocks restrictions', async () => { + await insertBlock( 'Allowed Blocks Dynamic' ); + const parentBlockSelector = '[data-type="test/allowed-blocks-dynamic"]'; + const blockAppender = '.block-list-appender button'; + const appenderSelector = `${ parentBlockSelector } ${ blockAppender }`; + await page.waitForSelector( appenderSelector ); + await page.click( appenderSelector ); + await openAllBlockInserterCategories(); + expect( + await getAllBlockInserterItemTitles() + ).toEqual( [ + 'Image', + 'List', + ] ); + const insertButton = ( await page.$x( + `//button//span[contains(text(), 'List')]` + ) )[ 0 ]; + await insertButton.click(); + await insertBlock( 'Image' ); + await page.click( appenderSelector ); + await openAllBlockInserterCategories(); + expect( + await getAllBlockInserterItemTitles() + ).toEqual( [ + 'Gallery', + 'Video', + ] ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js b/gb-src/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js new file mode 100644 index 0000000000000..0e87fdeefea61 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js @@ -0,0 +1,64 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, + saveDraft, + pressKeyTimes, +} from '@wordpress/e2e-test-utils'; + +describe( 'Block with a meta attribute', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-meta-attribute-block' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-meta-attribute-block' ); + } ); + + it( 'Should persist the meta attribute properly', async () => { + await insertBlock( 'Test Meta Attribute Block' ); + await page.keyboard.type( 'Value' ); + + // Regression Test: Previously the caret would wrongly reset to the end + // of any input for meta-sourced attributes, due to syncing behavior of + // meta attribute updates. + // + // See: https://github.com/WordPress/gutenberg/issues/15739 + await pressKeyTimes( 'ArrowLeft', 5 ); + await page.keyboard.type( 'Meta ' ); + + await saveDraft(); + await page.reload(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + const persistedValue = await page.evaluate( () => document.querySelector( '.my-meta-input' ).value ); + expect( persistedValue ).toBe( 'Meta Value' ); + } ); + + it( 'Should use the same value in all the blocks', async () => { + await insertBlock( 'Test Meta Attribute Block' ); + await insertBlock( 'Test Meta Attribute Block' ); + await insertBlock( 'Test Meta Attribute Block' ); + await page.keyboard.type( 'Meta Value' ); + + const inputs = await page.$$( '.my-meta-input' ); + await Promise.all( inputs.map( async ( input ) => { + // Clicking the input selects the block, + // and selecting the block enables the sync data mode + // as otherwise the asynchronous re-rendering of unselected blocks + // may cause the input to have not yet been updated for the other blocks + await input.click(); + const inputValue = await input.getProperty( 'value' ); + expect( await inputValue.jsonValue() ).toBe( 'Meta Value' ); + } ) ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/meta-boxes.test.js b/gb-src/packages/e2e-tests/specs/plugins/meta-boxes.test.js new file mode 100644 index 0000000000000..0a21dfe20d9d7 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/meta-boxes.test.js @@ -0,0 +1,121 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + findSidebarPanelToggleButtonWithTitle, + insertBlock, + openDocumentSettingsSidebar, + publishPost, + saveDraft, +} from '@wordpress/e2e-test-utils'; + +describe( 'Meta boxes', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-meta-box' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-meta-box' ); + } ); + + it( 'Should save the post', async () => { + // Save should not be an option for new empty post. + expect( await page.$( '.editor-post-save-draft' ) ).toBe( null ); + + // Add title to enable valid non-empty post save. + await page.type( '.editor-post-title__input', 'Hello Meta' ); + expect( await page.$( '.editor-post-save-draft' ) ).not.toBe( null ); + + await saveDraft(); + + // After saving, affirm that the button returns to Save Draft. + await page.waitForSelector( '.editor-post-save-draft' ); + } ); + + it( 'Should render dynamic blocks when the meta box uses the excerpt for front end rendering', async () => { + // Publish a post so there's something for the latest posts dynamic block to render. + await page.type( '.editor-post-title__input', 'A published post' ); + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + await publishPost(); + + // Publish a post with the latest posts dynamic block. + await createNewPost(); + await page.type( '.editor-post-title__input', 'Dynamic block test' ); + await insertBlock( 'Latest Posts' ); + await publishPost(); + + // View the post. + const viewPostLinks = await page.$x( "//a[contains(text(), 'View Post')]" ); + await viewPostLinks[ 0 ].click(); + await page.waitForNavigation(); + + // Check the the dynamic block appears. + await page.waitForSelector( '.wp-block-latest-posts' ); + } ); + + it( 'Should render the excerpt in meta based on post content if no explicit excerpt exists', async () => { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Excerpt from content.' ); + await page.type( '.editor-post-title__input', 'A published post' ); + await publishPost(); + + // View the post. + const viewPostLinks = await page.$x( "//a[contains(text(), 'View Post')]" ); + await viewPostLinks[ 0 ].click(); + await page.waitForNavigation(); + + // Retrieve the excerpt used as meta + const metaExcerpt = await page.evaluate( () => { + return document.querySelector( + 'meta[property="gutenberg:hello"]' + ).getAttribute( + 'content' + ); + } ); + + expect( metaExcerpt ).toEqual( 'Excerpt from content.' ); + } ); + + it( 'Should render the explicitly set excerpt in meta instead of the content based one', async () => { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Excerpt from content.' ); + await page.type( '.editor-post-title__input', 'A published post' ); + + // Open the excerpt panel + await openDocumentSettingsSidebar(); + const excerptButton = await findSidebarPanelToggleButtonWithTitle( 'Excerpt' ); + if ( excerptButton ) { + await excerptButton.click( 'button' ); + } + + await page.waitForSelector( '.editor-post-excerpt textarea' ); + + await page.type( '.editor-post-excerpt textarea', 'Explicitly set excerpt.' ); + + await publishPost(); + + // View the post. + const viewPostLinks = await page.$x( "//a[contains(text(), 'View Post')]" ); + await viewPostLinks[ 0 ].click(); + await page.waitForNavigation(); + + // Retrieve the excerpt used as meta + const metaExcerpt = await page.evaluate( () => { + return document.querySelector( + 'meta[property="gutenberg:hello"]' + ).getAttribute( + 'content' + ); + } ); + + expect( metaExcerpt ).toEqual( 'Explicitly set excerpt.' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/nonce.test.js b/gb-src/packages/e2e-tests/specs/plugins/nonce.test.js new file mode 100644 index 0000000000000..5fd76c4d11f51 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/nonce.test.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + saveDraft, +} from '@wordpress/e2e-test-utils'; + +describe( 'Nonce', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-nonce' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-nonce' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should refresh when expired', async () => { + await page.keyboard.press( 'Enter' ); + // eslint-disable-next-line no-restricted-syntax + await page.waitFor( 5000 ); + await page.keyboard.type( 'test' ); + // `saveDraft` waits for saving to be successful, so this test would + // timeout if it's not. + await saveDraft(); + // We expect a 403 status once. + expect( console ).toHaveErrored(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/plugins-api.test.js b/gb-src/packages/e2e-tests/specs/plugins/plugins-api.test.js new file mode 100644 index 0000000000000..fedf3f25503c4 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/plugins-api.test.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockAppender, + clickOnMoreMenuItem, + createNewPost, + deactivatePlugin, + openDocumentSettingsSidebar, + openPublishPanel, + publishPost, + setBrowserViewport, +} from '@wordpress/e2e-test-utils'; + +describe( 'Using Plugins API', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + describe( 'Post Status Info', () => { + it( 'Should render post status info inside Document Setting sidebar', async () => { + await openDocumentSettingsSidebar(); + + const pluginPostStatusInfoText = await page.$eval( '.edit-post-post-status .my-post-status-info-plugin', ( el ) => el.innerText ); + expect( pluginPostStatusInfoText ).toBe( 'My post status info' ); + } ); + } ); + + describe( 'Publish Panel', () => { + beforeEach( async () => { + // Type something first to activate Publish button. + await clickBlockAppender(); + await page.keyboard.type( 'First paragraph' ); + } ); + + it( 'Should render publish panel inside Pre-publish sidebar', async () => { + await openPublishPanel(); + + const pluginPublishPanelText = await page.$eval( '.editor-post-publish-panel .my-publish-panel-plugin__pre', ( el ) => el.innerText ); + expect( pluginPublishPanelText ).toMatch( 'My pre publish panel' ); + } ); + + it( 'Should render publish panel inside Post-publish sidebar', async () => { + await publishPost(); + + const pluginPublishPanelText = await page.$eval( '.editor-post-publish-panel .my-publish-panel-plugin__post', ( el ) => el.innerText ); + expect( pluginPublishPanelText ).toMatch( 'My post publish panel' ); + } ); + } ); + + describe( 'Sidebar', () => { + it( 'Should open plugins sidebar using More Menu item and render content', async () => { + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', ( el ) => el.innerHTML ); + expect( pluginSidebarContent ).toMatchSnapshot(); + } ); + + it( 'Should close plugins sidebar using More Menu item', async () => { + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + const pluginSidebarOpened = await page.$( '.edit-post-sidebar' ); + expect( pluginSidebarOpened ).not.toBeNull(); + + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + const pluginSidebarClosed = await page.$( '.edit-post-sidebar' ); + expect( pluginSidebarClosed ).toBeNull(); + } ); + + describe( 'Medium screen', () => { + beforeAll( async () => { + await setBrowserViewport( 'medium' ); + } ); + + afterAll( async () => { + await setBrowserViewport( 'large' ); + } ); + + it( 'Should open plugins sidebar using More Menu item and render content', async () => { + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', ( el ) => el.innerHTML ); + expect( pluginSidebarContent ).toMatchSnapshot(); + } ); + } ); + } ); + + describe( 'Document Setting Custom Panel', () => { + it( 'Should render a custom panel inside Document Setting sidebar', async () => { + await openDocumentSettingsSidebar(); + const pluginDocumentSettingsText = await page.$eval( '.edit-post-sidebar .my-document-setting-plugin', ( el ) => el.innerText ); + expect( pluginDocumentSettingsText ).toMatchSnapshot(); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/templates.test.js b/gb-src/packages/e2e-tests/specs/plugins/templates.test.js new file mode 100644 index 0000000000000..749842554fe66 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/templates.test.js @@ -0,0 +1,116 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockAppender, + createNewPost, + deactivatePlugin, + getEditedPostContent, + pressKeyWithModifier, + saveDraft, + switchUserToAdmin, + switchUserToTest, + visitAdminPage, +} from '@wordpress/e2e-test-utils'; + +describe( 'templates', () => { + describe( 'Using a CPT with a predefined template', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-templates' ); + } ); + + beforeEach( async () => { + await createNewPost( { postType: 'book' } ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-templates' ); + } ); + + it( 'Should add a custom post types with a predefined template', async () => { + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'Should respect user edits to not re-apply template after save (single block removal)', async () => { + // Remove a block from the template to verify that it's not + // re-added after saving and reloading the editor. + await page.click( '.editor-post-title__input' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Backspace' ); + await saveDraft(); + await page.reload(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'Should respect user edits to not re-apply template after save (full delete)', async () => { + // Remove all blocks from the template to verify that they're not + // re-added after saving and reloading the editor. + await page.type( '.editor-post-title__input', 'My Empty Book' ); + await page.keyboard.press( 'ArrowDown' ); + await pressKeyWithModifier( 'primary', 'A' ); + await page.keyboard.press( 'Backspace' ); + await saveDraft(); + await page.reload(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + } ); + + describe( 'With default post format assigned', () => { + const STANDARD_FORMAT_VALUE = '0'; + + async function setPostFormat( format ) { + // To set the post format, we need to be the admin user. + await switchUserToAdmin(); + await visitAdminPage( 'options-writing.php' ); + await page.select( '#default_post_format', format ); + await Promise.all( [ + page.waitForNavigation(), + page.click( '#submit' ), + ] ); + await switchUserToTest(); + } + + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-post-formats-support' ); + await setPostFormat( 'image' ); + } ); + afterAll( async () => { + await setPostFormat( STANDARD_FORMAT_VALUE ); + await deactivatePlugin( 'gutenberg-test-plugin-post-formats-support' ); + } ); + + it( 'should populate new post with default block for format', async () => { + await createNewPost(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not populate edited post with default block for format', async () => { + await createNewPost(); + + // Remove the default block template to verify that it's not + // re-added after saving and reloading the editor. + await page.type( '.editor-post-title__input', 'My Image Format' ); + await clickBlockAppender(); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.press( 'Backspace' ); + await saveDraft(); + await page.reload(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not populate new page with default block for format', async () => { + // This test always needs to run as the admin user, because other roles can't create pages. + // It can't be skipped, because then it failed because of not testing the snapshot. + await switchUserToAdmin(); + await createNewPost( { postType: 'page' } ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + await switchUserToTest(); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js b/gb-src/packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js new file mode 100644 index 0000000000000..74dd45374e946 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + publishPost, +} from '@wordpress/e2e-test-utils'; + +// This test isn't reliable on Travis and fails from time to time. +// See: https://github.com/WordPress/gutenberg/pull/15211. +describe.skip( 'WP Editor Meta Boxes', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-wp-editor-meta-box' ); + await createNewPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-wp-editor-meta-box' ); + } ); + + it( 'Should save the changes', async () => { + // Add title to enable valid non-empty post save. + await page.type( '.editor-post-title__input', 'Hello Meta' ); + + // Type something + await page.click( '#test_tinymce_id-html' ); + await page.type( '#test_tinymce_id', 'Typing in a metabox' ); + await page.click( '#test_tinymce_id-tmce' ); + + await publishPost(); + + await page.reload(); + + await page.click( '#test_tinymce_id-html' ); + const content = await page.$eval( + '#test_tinymce_id', + ( textarea ) => textarea.value + ); + + expect( content ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/popovers.test.js b/gb-src/packages/e2e-tests/specs/popovers.test.js new file mode 100644 index 0000000000000..5a1e17817dabf --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/popovers.test.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { createNewPost } from '@wordpress/e2e-test-utils'; + +describe( 'popovers', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + describe( 'dropdown', () => { + it( 'toggles via click', async () => { + const isMoreMenuOpen = async () => !! await page.$( '.edit-post-more-menu__content' ); + + expect( await isMoreMenuOpen() ).toBe( false ); + + // Toggle opened. + await page.click( '.edit-post-more-menu > button' ); + expect( await isMoreMenuOpen() ).toBe( true ); + + // Toggle closed. + await page.click( '.edit-post-more-menu > button' ); + expect( await isMoreMenuOpen() ).toBe( false ); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/post-visibility.test.js b/gb-src/packages/e2e-tests/specs/post-visibility.test.js new file mode 100644 index 0000000000000..0c93dcdef0f89 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/post-visibility.test.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { + setBrowserViewport, + createNewPost, + openDocumentSettingsSidebar, +} from '@wordpress/e2e-test-utils'; + +describe( 'Post visibility', () => { + [ 'large', 'small' ].forEach( ( viewport ) => { + it( `can be changed when the viewport is ${ viewport }`, async () => { + await setBrowserViewport( viewport ); + + await createNewPost(); + + await openDocumentSettingsSidebar(); + + await page.click( '.edit-post-post-visibility__toggle' ); + + const [ privateLabel ] = await page.$x( '//label[text()="Private"]' ); + await privateLabel.click(); + + const currentStatus = await page.evaluate( () => { + return wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); + } ); + + expect( currentStatus ).toBe( 'private' ); + } ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/preferences.test.js b/gb-src/packages/e2e-tests/specs/preferences.test.js new file mode 100644 index 0000000000000..24cf347d96501 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/preferences.test.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { createNewPost } from '@wordpress/e2e-test-utils'; + +describe( 'preferences', () => { + beforeAll( async () => { + await createNewPost(); + } ); + + /** + * Returns a promise which resolves to the text content of the active + * editor sidebar tab, or null if there is no active sidebar tab (closed). + * + * @return {Promise} Promise resolving to active tab. + */ + async function getActiveSidebarTabText() { + try { + return await page.$eval( + '.edit-post-sidebar__panel-tab.is-active', + ( node ) => node.textContent + ); + } catch ( error ) { + // page.$eval throws when it does not find the selector, which we + // can intentionally intercept and consider as there being no + // active sidebar tab (no sidebar). + return null; + } + } + + it( 'remembers sidebar dismissal between sessions', async () => { + // Open by default. + expect( await getActiveSidebarTabText() ).toBe( 'Document' ); + + // Change to "Block" tab. + await page.click( '.edit-post-sidebar__panel-tab[aria-label="Block"]' ); + expect( await getActiveSidebarTabText() ).toBe( 'Block' ); + + // Regression test: Reload resets to document tab. + // + // See: https://github.com/WordPress/gutenberg/issues/6377 + // See: https://github.com/WordPress/gutenberg/pull/8995 + await page.reload(); + expect( await getActiveSidebarTabText() ).toBe( 'Document' ); + + // Dismiss + await page.click( '.edit-post-sidebar__panel-tabs [aria-label="Close settings"]' ); + expect( await getActiveSidebarTabText() ).toBe( null ); + + // Remember after reload. + await page.reload(); + expect( await getActiveSidebarTabText() ).toBe( null ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/preview.test.js b/gb-src/packages/e2e-tests/specs/preview.test.js new file mode 100644 index 0000000000000..505f8cca3ba3a --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/preview.test.js @@ -0,0 +1,262 @@ +/** + * External dependencies + */ +import { last } from 'lodash'; +import { parse } from 'url'; + +/** + * WordPress dependencies + */ +import { + clickOnCloseModalButton, + createNewPost, + createURL, + publishPost, + saveDraft, + clickOnMoreMenuItem, + pressKeyWithModifier, + disableNavigationMode, +} from '@wordpress/e2e-test-utils'; + +async function openPreviewPage( editorPage ) { + let openTabs = await browser.pages(); + const expectedTabsCount = openTabs.length + 1; + await editorPage.click( '.editor-post-preview' ); + + // Wait for the new tab to open. + while ( openTabs.length < expectedTabsCount ) { + await editorPage.waitFor( 1 ); + openTabs = await browser.pages(); + } + + const previewPage = last( openTabs ); + // Wait for the preview to load. We can't do interstitial detection here, + // because it might load too quickly for us to pick up, so we wait for + // the preview to load by waiting for the title to appear. + await previewPage.waitForSelector( '.entry-title' ); + return previewPage; +} + +/** + * Given a Puppeteer Page instance for a preview window, clicks Preview, and + * awaits the window navigation. + * + * @param {puppeteer.Page} previewPage Page on which to await navigation. + * + * @return {Promise} Promise resolving once navigation completes. + */ +async function waitForPreviewNavigation( previewPage ) { + await page.click( '.editor-post-preview' ); + return previewPage.waitForNavigation(); +} + +/** + * Enables or disables the custom fields option. + * + * Note that this is implemented separately from the `toggleScreenOptions` + * utility, since the custom fields option triggers a page reload and requires + * extra async logic to wait for navigation to complete. + * + * @param {boolean} shouldBeChecked If true, turns the option on. If false, off. + */ +async function toggleCustomFieldsOption( shouldBeChecked ) { + const checkboxXPath = '//*[contains(@class, "edit-post-options-modal")]//label[contains(text(), "Custom Fields")]'; + await clickOnMoreMenuItem( 'Options' ); + await page.waitForXPath( checkboxXPath ); + const [ checkboxHandle ] = await page.$x( checkboxXPath ); + + const isChecked = await page.evaluate( + ( element ) => element.control.checked, + checkboxHandle + ); + + if ( isChecked !== shouldBeChecked ) { + await checkboxHandle.click(); + const [ saveButton ] = await page.$x( shouldBeChecked ? '//button[text()="Enable & Reload"]' : '//button[text()="Disable & Reload"]' ); + const navigationCompleted = page.waitForNavigation(); + saveButton.click(); + await navigationCompleted; + return; + } + + await clickOnCloseModalButton( '.edit-post-options-modal' ); +} + +describe( 'Preview', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should open a preview window for a new post', async () => { + const editorPage = page; + + // Disabled until content present. + const isPreviewDisabled = await editorPage.$$eval( + '.editor-post-preview:not( :disabled )', + ( enabledButtons ) => ! enabledButtons.length, + ); + expect( isPreviewDisabled ).toBe( true ); + + await editorPage.type( '.editor-post-title__input', 'Hello World' ); + + const previewPage = await openPreviewPage( editorPage ); + + // When autosave completes for a new post, the URL of the editor should + // update to include the ID. Use this to assert on preview URL. + const [ , postId ] = await ( await editorPage.waitForFunction( () => { + return window.location.search.match( /[\?&]post=(\d+)/ ); + } ) ).jsonValue(); + + const expectedPreviewURL = createURL( '', `?p=${ postId }&preview=true` ); + expect( previewPage.url() ).toBe( expectedPreviewURL ); + + // Title in preview should match input. + let previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World' ); + + // Return to editor to change title. + await editorPage.bringToFront(); + await editorPage.type( '.editor-post-title__input', '!' ); + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World!' ); + + // Pressing preview without changes should bring same preview window to + // front and reload, but should not show interstitial. + await editorPage.bringToFront(); + await waitForPreviewNavigation( previewPage ); + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World!' ); + + // Preview for published post (no unsaved changes) directs to canonical URL for post. + await editorPage.bringToFront(); + await publishPost(); + + // Return to editor to change title. + await editorPage.bringToFront(); + await editorPage.type( '.editor-post-title__input', ' And more.' ); + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World! And more.' ); + + // Published preview URL should include ID and nonce parameters. + const { query } = parse( previewPage.url(), true ); + expect( query ).toHaveProperty( 'preview_id' ); + expect( query ).toHaveProperty( 'preview_nonce' ); + + // Return to editor. Previewing already-autosaved preview tab should + // reuse the opened tab, skipping interstitial. This resolves an edge + // cases where the post is dirty but not autosaveable (because the + // autosave is already up-to-date). + // + // See: https://github.com/WordPress/gutenberg/issues/7561 + await editorPage.bringToFront(); + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World! And more.' ); + + await previewPage.close(); + } ); + + it( 'should not revert title during a preview right after a save draft', async () => { + const editorPage = page; + + // Type aaaaa in the title filed. + await editorPage.type( '.editor-post-title__input', 'aaaaa' ); + await editorPage.keyboard.press( 'Tab' ); + + // Save the post as a draft. + await editorPage.waitForSelector( '.editor-post-save-draft' ); + await saveDraft(); + + // Open the preview page. + const previewPage = await openPreviewPage( editorPage ); + + // Title in preview should match input. + let previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'aaaaa' ); + + // Return to editor. + await editorPage.bringToFront(); + + // Append bbbbb to the title, and tab away from the title so blur event is triggered. + await editorPage.type( '.editor-post-title__input', 'bbbbb' ); + await editorPage.keyboard.press( 'Tab' ); + + // Save draft and open the preview page right after. + await editorPage.waitForSelector( '.editor-post-save-draft' ); + await saveDraft(); + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'aaaaabbbbb' ); + + await previewPage.close(); + } ); +} ); + +describe( 'Preview with Custom Fields enabled', () => { + beforeEach( async () => { + await createNewPost(); + await toggleCustomFieldsOption( true ); + await disableNavigationMode(); + } ); + + afterEach( async () => { + await toggleCustomFieldsOption( false ); + } ); + + // Catch regressions of https://github.com/WordPress/gutenberg/issues/12617 + it( 'displays edits to the post title and content in the preview', async () => { + const editorPage = page; + + // Add an initial title and content. + await editorPage.type( '.editor-post-title__input', 'title 1' ); + await editorPage.keyboard.press( 'Tab' ); + await editorPage.keyboard.type( 'content 1' ); + + // Publish the post and then close the publish panel. + await publishPost(); + await page.waitForSelector( '.editor-post-publish-panel' ); + await page.click( '.editor-post-publish-panel__header button' ); + + // Open the preview page. + const previewPage = await openPreviewPage( editorPage ); + + // Check the title and preview match. + let previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'title 1' ); + let previewContent = await previewPage.$eval( '.entry-content p', ( node ) => node.textContent ); + expect( previewContent ).toBe( 'content 1' ); + + // Return to editor and modify the title and content. + await editorPage.bringToFront(); + await editorPage.click( '.editor-post-title__input' ); + await pressKeyWithModifier( 'primary', 'a' ); + await editorPage.keyboard.press( 'Delete' ); + await editorPage.keyboard.type( 'title 2' ); + await editorPage.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'primary', 'a' ); + await editorPage.keyboard.press( 'Delete' ); + await editorPage.keyboard.type( 'content 2' ); + + // Open the preview page. + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'title 2' ); + previewContent = await previewPage.$eval( '.entry-content p', ( node ) => node.textContent ); + expect( previewContent ).toBe( 'content 2' ); + + // Make sure the editor is active for the afterEach function. + await editorPage.bringToFront(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/publish-button.test.js b/gb-src/packages/e2e-tests/specs/publish-button.test.js new file mode 100644 index 0000000000000..ecdd6bf76b000 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/publish-button.test.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { + arePrePublishChecksEnabled, + disablePrePublishChecks, + enablePrePublishChecks, + createNewPost, +} from '@wordpress/e2e-test-utils'; + +describe( 'PostPublishButton', () => { + let werePrePublishChecksEnabled; + beforeEach( async () => { + await createNewPost( ); + werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); + if ( werePrePublishChecksEnabled ) { + await disablePrePublishChecks(); + } + } ); + + afterEach( async () => { + if ( werePrePublishChecksEnabled ) { + await enablePrePublishChecks(); + } + } ); + + it( 'should be disabled when post is not saveable', async () => { + const publishButton = await page.$( '.editor-post-publish-button[aria-disabled="true"]' ); + expect( publishButton ).not.toBeNull(); + } ); + + it( 'should be disabled when post is being saved', async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); // Make it saveable + expect( await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).toBeNull(); + + await page.click( '.editor-post-save-draft' ); + expect( await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).not.toBeNull(); + } ); + + it( 'should be disabled when metabox is being saved', async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); // Make it saveable + expect( await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).toBeNull(); + + await page.evaluate( () => window.wp.data.dispatch( 'core/edit-post' ).requestMetaBoxUpdates() ); + expect( await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).not.toBeNull(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/publish-panel.test.js b/gb-src/packages/e2e-tests/specs/publish-panel.test.js new file mode 100644 index 0000000000000..c28a3837d7443 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/publish-panel.test.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { + arePrePublishChecksEnabled, + disablePrePublishChecks, + enablePrePublishChecks, + createNewPost, + openPublishPanel, + pressKeyWithModifier, + publishPost, +} from '@wordpress/e2e-test-utils'; + +describe( 'PostPublishPanel', () => { + let werePrePublishChecksEnabled; + beforeEach( async () => { + await createNewPost( ); + werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); + if ( ! werePrePublishChecksEnabled ) { + await enablePrePublishChecks(); + } + } ); + + afterEach( async () => { + if ( ! werePrePublishChecksEnabled ) { + await disablePrePublishChecks(); + } + } ); + + it( 'PrePublish: publish button should have the focus', async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await openPublishPanel(); + + const focusedElementClassList = await page.$eval( ':focus', ( focusedElement ) => { + return Object.values( focusedElement.classList ); + } ); + expect( focusedElementClassList ).toContain( 'editor-post-publish-button' ); + } ); + + it( 'PostPublish: post link should have the focus', async () => { + const postTitle = 'E2E Test Post'; + await page.type( '.editor-post-title__input', postTitle ); + await publishPost(); + + const focusedElementTag = await page.$eval( ':focus', ( focusedElement ) => { + return focusedElement.tagName.toLowerCase(); + } ); + const focusedElementText = await page.$eval( ':focus', ( focusedElement ) => { + return focusedElement.text; + } ); + expect( focusedElementTag ).toBe( 'a' ); + expect( focusedElementText ).toBe( postTitle ); + } ); + + it( 'should retain focus within the panel', async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await openPublishPanel(); + await pressKeyWithModifier( 'shift', 'Tab' ); + + const focusedElementClassList = await page.$eval( ':focus', ( focusedElement ) => { + return Object.values( focusedElement.classList ); + } ); + expect( focusedElementClassList ).toContain( 'components-checkbox-control__input' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/publishing.test.js b/gb-src/packages/e2e-tests/specs/publishing.test.js new file mode 100644 index 0000000000000..64b8705ef8bab --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/publishing.test.js @@ -0,0 +1,112 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + publishPost, + publishPostWithPrePublishChecksDisabled, + enablePrePublishChecks, + disablePrePublishChecks, + arePrePublishChecksEnabled, + setBrowserViewport, +} from '@wordpress/e2e-test-utils'; + +describe( 'Publishing', () => { + describe.each( [ 'post', 'page' ] )( + 'a %s', + ( postType ) => { + let werePrePublishChecksEnabled; + + beforeEach( async () => { + await createNewPost( postType ); + werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); + if ( ! werePrePublishChecksEnabled ) { + await enablePrePublishChecks(); + } + } ); + + afterEach( async () => { + if ( ! werePrePublishChecksEnabled ) { + await disablePrePublishChecks(); + } + } ); + + it( `should publish the ${ postType } and close the panel once we start editing again.`, async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); + + await publishPost(); + + // The post-publishing panel is visible. + expect( await page.$( '.editor-post-publish-panel' ) ).not.toBeNull(); + + // Start editing again. + await page.type( '.editor-post-title__input', ' (Updated)' ); + + // The post-publishing panel is not visible anymore. + expect( await page.$( '.editor-post-publish-panel' ) ).toBeNull(); + } ); + } + ); + + describe.each( [ 'post', 'page' ] )( + 'a %s with pre-publish checks disabled', + ( postType ) => { + let werePrePublishChecksEnabled; + + beforeEach( async () => { + await createNewPost( postType ); + werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); + if ( werePrePublishChecksEnabled ) { + await disablePrePublishChecks(); + } + } ); + + afterEach( async () => { + if ( werePrePublishChecksEnabled ) { + await enablePrePublishChecks(); + } + } ); + + it( `should publish the ${ postType } without opening the post-publish sidebar.`, async () => { + await page.type( '.editor-post-title__input', 'E2E Test Post' ); + + // The "Publish" button should be shown instead of the "Publish..." toggle + expect( await page.$( '.editor-post-publish-panel__toggle' ) ).toBeNull(); + expect( await page.$( '.editor-post-publish-button' ) ).not.toBeNull(); + + await publishPostWithPrePublishChecksDisabled(); + + // The post-publishing panel should have been not shown. + expect( await page.$( '.editor-post-publish-panel' ) ).toBeNull(); + } ); + } + ); + + describe.each( [ 'post', 'page' ] )( + 'a %s in small viewports', + ( postType ) => { + let werePrePublishChecksEnabled; + + beforeEach( async () => { + await createNewPost( postType ); + werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); + if ( werePrePublishChecksEnabled ) { + await disablePrePublishChecks(); + } + await setBrowserViewport( 'small' ); + } ); + + afterEach( async () => { + await setBrowserViewport( 'large' ); + if ( werePrePublishChecksEnabled ) { + await enablePrePublishChecks(); + } + } ); + + it( `should ignore the pre-publish checks and show the Publish... toggle instead of the Publish button`, async () => { + expect( await page.$( '.editor-post-publish-panel__toggle' ) ).not.toBeNull(); + expect( await page.$( '.editor-post-publish-button' ) ).toBeNull(); + } ); + } + ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/reusable-blocks.test.js b/gb-src/packages/e2e-tests/specs/reusable-blocks.test.js new file mode 100644 index 0000000000000..0cc0232acd6b2 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/reusable-blocks.test.js @@ -0,0 +1,264 @@ +/** + * WordPress dependencies + */ +import { + insertBlock, + createNewPost, + clickBlockToolbarButton, + pressKeyWithModifier, + searchForBlock, + getEditedPostContent, +} from '@wordpress/e2e-test-utils'; + +function waitForAndAcceptDialog() { + return new Promise( ( resolve ) => { + page.once( 'dialog', () => resolve() ); + } ); +} + +describe( 'Reusable Blocks', () => { + beforeAll( async () => { + await createNewPost(); + } ); + + beforeEach( async () => { + // Remove all blocks from the post so that we're working with a clean slate + await page.evaluate( () => { + const blocks = wp.data.select( 'core/block-editor' ).getBlocks(); + const clientIds = blocks.map( ( block ) => block.clientId ); + wp.data.dispatch( 'core/block-editor' ).removeBlocks( clientIds ); + } ); + } ); + + it( 'can be created', async () => { + // Insert a paragraph block + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + + await clickBlockToolbarButton( 'More options' ); + + const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' ); + await convertButton.click(); + + // Wait for creation to finish + await page.waitForXPath( + '//*[contains(@class, "components-snackbar")]/*[text()="Block created."]' + ); + + // Select all of the text in the title field. + await pressKeyWithModifier( 'primary', 'a' ); + + // Give the reusable block a title + await page.keyboard.type( 'Greeting block' ); + + // Save the reusable block + const [ saveButton ] = await page.$x( '//button[text()="Save"]' ); + await saveButton.click(); + + // Wait for saving to finish + await page.waitForXPath( '//button[text()="Edit"]' ); + + // Check that we have a reusable block on the page + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); + expect( block ).not.toBeNull(); + + // Check that its title is displayed + const title = await page.$eval( + '.reusable-block-edit-panel__info', + ( element ) => element.innerText + ); + expect( title ).toBe( 'Greeting block' ); + } ); + + it( 'can be created with no title', async () => { + // Insert a paragraph block + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + + await clickBlockToolbarButton( 'More options' ); + + const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' ); + await convertButton.click(); + + // Wait for creation to finish + await page.waitForXPath( + '//*[contains(@class, "components-snackbar")]/*[text()="Block created."]' + ); + + // Save the reusable block + const [ saveButton ] = await page.$x( '//button[text()="Save"]' ); + await saveButton.click(); + + // Wait for saving to finish + await page.waitForXPath( '//button[text()="Edit"]' ); + + // Check that we have a reusable block on the page + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); + expect( block ).not.toBeNull(); + + // Check that it is untitled + const title = await page.$eval( + '.reusable-block-edit-panel__info', + ( element ) => element.innerText + ); + expect( title ).toBe( 'Untitled Reusable Block' ); + } ); + + it( 'can be inserted and edited', async () => { + // Insert the reusable block we created above + await insertBlock( 'Greeting block' ); + + // Put the reusable block in edit mode + const [ editButton ] = await page.$x( '//button[text()="Edit"]' ); + await editButton.click(); + + // Change the block's title + await page.keyboard.type( 'Surprised greeting block' ); + + // Tab three times to navigate to the block's content + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); // Enter edit mode + + // Change the block's content + await page.keyboard.type( 'Oh! ' ); + + // Save the reusable block + const [ saveButton ] = await page.$x( '//button[text()="Save"]' ); + await saveButton.click(); + + // Wait for saving to finish + await page.waitForXPath( '//button[text()="Edit"]' ); + + // Check that we have a reusable block on the page + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); + expect( block ).not.toBeNull(); + + // Check that its title is displayed + const title = await page.$eval( + '.reusable-block-edit-panel__info', + ( element ) => element.innerText + ); + expect( title ).toBe( 'Surprised greeting block' ); + + // Check that its content is up to date + const text = await page.$eval( + '.block-editor-block-list__block[data-type="core/block"] .block-editor-rich-text', + ( element ) => element.innerText + ); + expect( text ).toMatch( 'Oh! Hello there!' ); + } ); + + it( 'can be converted to a regular block', async () => { + // Insert the reusable block we edited above + await insertBlock( 'Surprised greeting block' ); + + // Convert block to a regular block + await clickBlockToolbarButton( 'More options' ); + const convertButton = await page.waitForXPath( + '//button[text()="Convert to Regular Block"]' + ); + await convertButton.click(); + + // Check that we have a paragraph block on the page + const block = await page.$( '.block-editor-block-list__block[data-type="core/paragraph"]' ); + expect( block ).not.toBeNull(); + + // Check that its content is up to date + const text = await page.$eval( + '.block-editor-block-list__block[data-type="core/paragraph"] .block-editor-rich-text', + ( element ) => element.innerText + ); + expect( text ).toMatch( 'Oh! Hello there!' ); + } ); + + it( 'can be deleted', async () => { + // Insert the reusable block we edited above + await insertBlock( 'Surprised greeting block' ); + + // Delete the block and accept the confirmation dialog + await clickBlockToolbarButton( 'More options' ); + const deleteButton = await page.waitForXPath( '//button[text()="Remove from Reusable Blocks"]' ); + await Promise.all( [ waitForAndAcceptDialog(), deleteButton.click() ] ); + + // Wait for deletion to finish + await page.waitForXPath( + '//*[contains(@class, "components-snackbar")]/*[text()="Block deleted."]' + ); + + // Check that we have an empty post again + expect( await getEditedPostContent() ).toBe( '' ); + + // Search for the block in the inserter + await searchForBlock( 'Surprised greeting block' ); + + // Check that we couldn't find it + const items = await page.$$( + '.block-editor-block-types-list__item[aria-label="Surprised greeting block"]' + ); + expect( items ).toHaveLength( 0 ); + } ); + + it( 'can be created from multiselection', async () => { + await createNewPost(); + + // Insert a Two paragraphs block + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second paragraph' ); + + // Select all the blocks + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + // Convert block to a reusable block + await clickBlockToolbarButton( 'More options' ); + const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' ); + await convertButton.click(); + + // Wait for creation to finish + await page.waitForXPath( + '//*[contains(@class, "components-snackbar")]/*[text()="Block created."]' + ); + + // Select all of the text in the title field. + await pressKeyWithModifier( 'primary', 'a' ); + + // Give the reusable block a title + await page.keyboard.type( 'Multi-selection reusable block' ); + + // Save the reusable block + const [ saveButton ] = await page.$x( '//button[text()="Save"]' ); + await saveButton.click(); + + // Wait for saving to finish + await page.waitForXPath( '//button[text()="Edit"]' ); + + // Check that we have a reusable block on the page + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); + expect( block ).not.toBeNull(); + + // Check that its title is displayed + const title = await page.$eval( + '.reusable-block-edit-panel__info', + ( element ) => element.innerText + ); + expect( title ).toBe( 'Multi-selection reusable block' ); + } ); + + it( 'multi-selection reusable block can be converted back to regular blocks', async () => { + // Insert the reusable block we edited above + await insertBlock( 'Multi-selection reusable block' ); + + // Convert block to a regular block + await clickBlockToolbarButton( 'More options' ); + const convertButton = await page.waitForXPath( + '//button[text()="Convert to Regular Block"]' + ); + await convertButton.click(); + + // Check that we have two paragraph blocks on the page + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/rich-text.test.js b/gb-src/packages/e2e-tests/specs/rich-text.test.js new file mode 100644 index 0000000000000..9c00e0d062c14 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/rich-text.test.js @@ -0,0 +1,287 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + getEditedPostContent, + insertBlock, + clickBlockAppender, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +describe( 'RichText', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should handle change in tag name gracefully', async () => { + // Regression test: The heading block changes the tag name of its + // RichText element. Historically this has been prone to breakage, + // because the Editable component prevents rerenders, so React cannot + // update the element by itself. + // + // See: https://github.com/WordPress/gutenberg/issues/3091 + await insertBlock( 'Heading' ); + await page.click( '[aria-label="Heading 3"]' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should apply formatting with primary shortcut', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'test' ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'b' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should apply formatting when selection is collapsed', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'Some ' ); + // All following characters should now be bold. + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( 'bold' ); + // All following characters should no longer be bold. + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should apply multiple formats when selection is collapsed', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'i' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'i' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not highlight more than one format', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( ' 2' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'primary', 'b' ); + + const count = await page.evaluate( () => document.querySelectorAll( + '*[data-rich-text-format-boundary]' + ).length ); + + expect( count ).toBe( 1 ); + } ); + + it( 'should return focus when pressing formatting button', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'Some ' ); + await page.mouse.move( 0, 0 ); + await page.mouse.move( 10, 10 ); + await page.click( '[aria-label="Bold"]' ); + await page.keyboard.type( 'bold' ); + await page.mouse.move( 0, 0 ); + await page.mouse.move( 10, 10 ); + await page.click( '[aria-label="Bold"]' ); + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should transform backtick to code', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'A `backtick`' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should undo backtick transform with backspace', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'Backspace' ); + + // Expect "`a`" to be restored. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo backtick transform with backspace after typing', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.type( 'b' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.press( 'Backspace' ); + + // Expect "a" to be deleted. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo backtick transform with backspace after selection change', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + // Move inside format boundary. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Backspace' ); + + // Expect "a" to be deleted. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not format text after code backtick', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'A `backtick` and more.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should only mutate text data on input', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '2' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '3' ); + + await page.evaluate( () => { + let called; + const { body } = document; + const config = { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }; + + const mutationObserver = new MutationObserver( ( records ) => { + if ( called || records.length > 1 ) { + throw new Error( 'Typing should only mutate once.' ); + } + + records.forEach( ( record ) => { + if ( record.type !== 'characterData' ) { + throw new Error( + `Typing mutated more than character data: ${ record.type }` + ); + } + } ); + + called = true; + } ); + + mutationObserver.observe( body, config ); + + window.unsubscribes = [ () => mutationObserver.disconnect() ]; + + document.addEventListener( 'selectionchange', () => { + function throwMultipleSelectionChange() { + throw new Error( 'Typing should only emit one selection change event.' ); + } + + document.addEventListener( + 'selectionchange', + throwMultipleSelectionChange, + { once: true } + ); + + window.unsubscribes.push( () => { + document.removeEventListener( 'selectionchange', throwMultipleSelectionChange ); + } ); + }, { once: true } ); + } ); + + await page.keyboard.type( '4' ); + + await page.evaluate( () => { + // The selection change event should be called once. If there's only + // one item in `window.unsubscribes`, it means that only one + // function is present to disconnect the `mutationObserver`. + if ( window.unsubscribes.length === 1 ) { + throw new Error( 'The selection change event listener was never called.' ); + } + + window.unsubscribes.forEach( ( unsubscribe ) => unsubscribe() ); + } ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not lose selection direction', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '23' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.up( 'Shift' ); + + // There should be no selection. The following should insert "-" without + // deleting the numbers. + await page.keyboard.type( '-' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should handle Home and End keys', async () => { + await page.keyboard.press( 'Enter' ); + + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '12' ); + await pressKeyWithModifier( 'primary', 'b' ); + + await page.keyboard.press( 'Home' ); + await page.keyboard.type( '-' ); + await page.keyboard.press( 'End' ); + await page.keyboard.type( '+' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should update internal selection after fresh focus', async () => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '2' ); + await pressKeyWithModifier( 'primary', 'b' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should keep internal selection after blur', async () => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '1' ); + // Simulate moving focus to a different app, then moving focus back, + // without selection being changed. + await page.evaluate( () => { + const activeElement = document.activeElement; + activeElement.blur(); + activeElement.focus(); + } ); + // Wait for the next animation frame, see the focus event listener in + // RichText. + await page.evaluate( () => new Promise( window.requestAnimationFrame ) ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '2' ); + await pressKeyWithModifier( 'primary', 'b' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/rtl.test.js b/gb-src/packages/e2e-tests/specs/rtl.test.js new file mode 100644 index 0000000000000..76e6d15ff9aa3 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/rtl.test.js @@ -0,0 +1,122 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + getEditedPostContent, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +// Avoid using three, as it looks too much like two with some fonts. +const ARABIC_ZERO = '٠'; +const ARABIC_ONE = '١'; +const ARABIC_TWO = '٢'; + +describe( 'RTL', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should arrow navigate', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + // We need at least three characters as arrow navigation *from* the + // edges might be handled differently. + await page.keyboard.type( ARABIC_ONE ); + await page.keyboard.type( ARABIC_TWO ); + await page.keyboard.press( 'ArrowRight' ); + // This is the important key press: arrow nav *from* the middle. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( ARABIC_ZERO ); + + // Expect: ARABIC_ZERO + ARABIC_ONE + ARABIC_TWO (

٠١٢

). + // N.b.: HTML is LTR, so direction will be reversed! + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should split', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + await page.keyboard.type( ARABIC_ZERO ); + await page.keyboard.type( ARABIC_ONE ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should merge backward', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + await page.keyboard.type( ARABIC_ZERO ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ARABIC_ONE ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should merge forward', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + await page.keyboard.type( ARABIC_ZERO ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ARABIC_ONE ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Delete' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should arrow navigate between blocks', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + await page.keyboard.type( ARABIC_ZERO ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ARABIC_ONE ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( ARABIC_TWO ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + + // Move to the previous block with two lines in the current block. + await page.keyboard.press( 'ArrowRight' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( ARABIC_ONE ); + + // Move to the next block with two lines in the current block. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( ARABIC_ZERO ); + await pressKeyWithModifier( 'shift', 'Enter' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should navigate inline boundaries', async () => { + await page.evaluate( () => document.dir = 'rtl' ); + await page.keyboard.press( 'Enter' ); + + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( ARABIC_ONE ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( ARABIC_TWO ); + + // Insert a character at each boundary position. + for ( let i = 4; i > 0; i-- ) { + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( ARABIC_ZERO ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Backspace' ); + } + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/shortcut-help.test.js b/gb-src/packages/e2e-tests/specs/shortcut-help.test.js new file mode 100644 index 0000000000000..03c53949b436d --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/shortcut-help.test.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + clickOnMoreMenuItem, + clickOnCloseModalButton, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +describe( 'keyboard shortcut help modal', () => { + beforeAll( async () => { + await createNewPost(); + } ); + + it( 'displays the shortcut help modal when opened using the menu item in the more menu', async () => { + await clickOnMoreMenuItem( 'Keyboard Shortcuts' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 1 ); + } ); + + it( 'closes the shortcut help modal when the close icon is clicked', async () => { + await clickOnCloseModalButton(); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 0 ); + } ); + + it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { + await pressKeyWithModifier( 'access', 'h' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 1 ); + } ); + + it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => { + await pressKeyWithModifier( 'access', 'h' ); + const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); + expect( shortcutHelpModalElements ).toHaveLength( 0 ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/sidebar-permalink-panel.test.js b/gb-src/packages/e2e-tests/specs/sidebar-permalink-panel.test.js new file mode 100644 index 0000000000000..16de106f570e6 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/sidebar-permalink-panel.test.js @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + findSidebarPanelWithTitle, + openDocumentSettingsSidebar, + publishPost, +} from '@wordpress/e2e-test-utils'; + +// This tests are not together with the remaining sidebar tests, +// because we need to publish/save a post, to correctly test the permalink panel. +// The sidebar test suit enforces that focus is never lost, but during save operations +// the focus is lost and a new element is focused once the save is completed. +describe( 'Sidebar Permalink Panel', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-custom-post-types' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-custom-post-types' ); + } ); + + it( 'should not render permalink sidebar panel while the post is new', async () => { + await createNewPost(); + await openDocumentSettingsSidebar(); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeUndefined(); + } ); + + it( 'should render permalink sidebar panel after the post is published and allow its removal', async () => { + await createNewPost(); + await page.keyboard.type( 'aaaaa' ); + await publishPost(); + // Start editing again. + await page.type( '.editor-post-title__input', ' (Updated)' ); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeDefined(); + await page.evaluate( () => { + const { removeEditorPanel } = wp.data.dispatch( 'core/edit-post' ); + removeEditorPanel( 'post-link' ); + } ); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeUndefined(); + } ); + + it( 'should not render link panel when post is publicly queryable but not public', async () => { + await createNewPost( { postType: 'public_q_not_public' } ); + await page.keyboard.type( 'aaaaa' ); + await publishPost(); + // Start editing again. + await page.type( '.editor-post-title__input', ' (Updated)' ); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeUndefined(); + } ); + + it( 'should not render link panel when post is public but not publicly queryable', async () => { + await createNewPost( { postType: 'not_public_q_public' } ); + await page.keyboard.type( 'aaaaa' ); + await publishPost(); + // Start editing again. + await page.type( '.editor-post-title__input', ' (Updated)' ); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeUndefined(); + } ); + + it( 'should render link panel when post is public and publicly queryable', async () => { + await createNewPost( { postType: 'public_q_public' } ); + await page.keyboard.type( 'aaaaa' ); + await publishPost(); + // Start editing again. + await page.type( '.editor-post-title__input', ' (Updated)' ); + expect( await findSidebarPanelWithTitle( 'Permalink' ) ).toBeDefined(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/sidebar.test.js b/gb-src/packages/e2e-tests/specs/sidebar.test.js new file mode 100644 index 0000000000000..d0768f916f0f6 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/sidebar.test.js @@ -0,0 +1,143 @@ +/** + * WordPress dependencies + */ +import { + clearLocalStorage, + createNewPost, + findSidebarPanelWithTitle, + observeFocusLoss, + openDocumentSettingsSidebar, + pressKeyWithModifier, + setBrowserViewport, +} from '@wordpress/e2e-test-utils'; + +const SIDEBAR_SELECTOR = '.edit-post-sidebar'; +const ACTIVE_SIDEBAR_TAB_SELECTOR = '.edit-post-sidebar__panel-tab.is-active'; +const ACTIVE_SIDEBAR_BUTTON_TEXT = 'Document'; + +describe( 'Sidebar', () => { + beforeAll( () => { + observeFocusLoss(); + } ); + + it( 'should have sidebar visible at the start with document sidebar active on desktop', async () => { + await setBrowserViewport( 'large' ); + await clearLocalStorage(); + await createNewPost(); + const { nodesCount, content, height, width } = await page.$$eval( ACTIVE_SIDEBAR_TAB_SELECTOR, ( nodes ) => { + const firstNode = nodes[ 0 ]; + return { + nodesCount: nodes.length, + content: firstNode.innerText, + height: firstNode.offsetHeight, + width: firstNode.offsetWidth, + }; + } ); + + // should have only one active sidebar tab. + expect( nodesCount ).toBe( 1 ); + + // the active sidebar tab should be document. + expect( content ).toBe( ACTIVE_SIDEBAR_BUTTON_TEXT ); + + // the active sidebar tab should be visible + expect( width ).toBeGreaterThan( 10 ); + expect( height ).toBeGreaterThan( 10 ); + } ); + + it( 'should have the sidebar closed by default on mobile', async () => { + await setBrowserViewport( 'small' ); + await clearLocalStorage(); + await createNewPost(); + const sidebar = await page.$( SIDEBAR_SELECTOR ); + expect( sidebar ).toBeNull(); + } ); + + it( 'should close the sidebar when resizing from desktop to mobile', async () => { + await setBrowserViewport( 'large' ); + await clearLocalStorage(); + await createNewPost(); + + const sidebars = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebars ).toHaveLength( 1 ); + + await setBrowserViewport( 'small' ); + + const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); + // sidebar should be closed when resizing to mobile. + expect( sidebarsMobile ).toHaveLength( 0 ); + } ); + + it( 'should reopen sidebar the sidebar when resizing from mobile to desktop if the sidebar was closed automatically', async () => { + await setBrowserViewport( 'large' ); + await clearLocalStorage(); + await createNewPost(); + await setBrowserViewport( 'small' ); + + const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebarsMobile ).toHaveLength( 0 ); + + await setBrowserViewport( 'large' ); + + const sidebarsDesktop = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebarsDesktop ).toHaveLength( 1 ); + } ); + + it( 'should preserve tab order while changing active tab', async () => { + await createNewPost(); + + // Region navigate to Sidebar. + await pressKeyWithModifier( 'ctrl', '`' ); + await pressKeyWithModifier( 'ctrl', '`' ); + await pressKeyWithModifier( 'ctrl', '`' ); + await pressKeyWithModifier( 'ctrl', '`' ); + + // Tab lands at first (presumed selected) option "Document". + await page.keyboard.press( 'Tab' ); + const isActiveDocumentTab = await page.evaluate( () => ( + document.activeElement.textContent === 'Document' && + document.activeElement.classList.contains( 'is-active' ) + ) ); + expect( isActiveDocumentTab ).toBe( true ); + + // Tab into and activate "Block". + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Space' ); + const isActiveBlockTab = await page.evaluate( () => ( + document.activeElement.textContent === 'Block' && + document.activeElement.classList.contains( 'is-active' ) + ) ); + expect( isActiveBlockTab ).toBe( true ); + } ); + + it( 'should be possible to programmatically remove Document Settings panels', async () => { + await createNewPost(); + + await openDocumentSettingsSidebar(); + + expect( await findSidebarPanelWithTitle( 'Categories' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Tags' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Featured Image' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Excerpt' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Discussion' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Status & Visibility' ) ).toBeDefined(); + + await page.evaluate( () => { + const { removeEditorPanel } = wp.data.dispatch( 'core/edit-post' ); + + removeEditorPanel( 'taxonomy-panel-category' ); + removeEditorPanel( 'taxonomy-panel-post_tag' ); + removeEditorPanel( 'featured-image' ); + removeEditorPanel( 'post-excerpt' ); + removeEditorPanel( 'discussion-panel' ); + removeEditorPanel( 'post-status' ); + } ); + + expect( await findSidebarPanelWithTitle( 'Categories' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Tags' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Featured Image' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Excerpt' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Discussion' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Status & Visibility' ) ).toBeUndefined(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/splitting-merging.test.js b/gb-src/packages/e2e-tests/specs/splitting-merging.test.js new file mode 100644 index 0000000000000..8c3559e3f3645 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/splitting-merging.test.js @@ -0,0 +1,215 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + insertBlock, + isInDefaultBlock, + getEditedPostContent, + pressKeyTimes, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +describe( 'splitting and merging blocks', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should split and merge paragraph blocks using Enter and Backspace', async () => { + // Use regular inserter to add paragraph block and text + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'FirstSecond' ); + + // Move caret between 'First' and 'Second' and press Enter to split + // paragraph blocks + await pressKeyTimes( 'ArrowLeft', 6 ); + await page.keyboard.press( 'Enter' ); + + // Assert that there are now two paragraph blocks with correct content + expect( await getEditedPostContent() ).toMatchSnapshot(); + + // Press Backspace to merge paragraph blocks + await page.keyboard.press( 'Backspace' ); + + // Ensure that caret position is correctly placed at the between point. + await page.keyboard.type( 'Between' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressKeyTimes( 'Backspace', 7 ); // Delete "Between" + + // Edge case: Without ensuring that the editor still has focus when + // restoring a bookmark, the caret may be inadvertently moved back to + // an inline boundary after a split occurs. + await page.keyboard.press( 'Home' ); + await page.keyboard.down( 'Shift' ); + await pressKeyTimes( 'ArrowRight', 5 ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'b' ); + // Collapse selection, still within inline boundary. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'BeforeSecond:' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should merge into inline boundary position', async () => { + // Regression Test: Caret should reset to end of inline boundary when + // backspacing to delete second paragraph. + await insertBlock( 'Paragraph' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( 'Foo' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Backspace' ); + + // Replace contents of first paragraph with "Bar". + await pressKeyTimes( 'Backspace', 4 ); + await page.keyboard.type( 'Bar' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should delete an empty first line', async () => { + // Regression Test: When a paragraph block has line break, and the first + // line has no text, pressing backspace at the start of the second line + // should remove the first. + // + // See: https://github.com/WordPress/gutenberg/issues/8388 + + // First paragraph + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'First' ); + await page.keyboard.press( 'Enter' ); + + // Second paragraph + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.up( 'Shift' ); + + // Delete the soft line break. + await page.keyboard.press( 'Backspace' ); + + // Typing at this point should occur still within the second paragraph, + // while before the regression fix it would have occurred in the first. + await page.keyboard.type( 'Still Second' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not merge paragraphs if the selection is not collapsed', async () => { + // Regression Test: When all of a paragraph is selected, pressing + // backspace should delete the contents, not merge to previous. + // + // See: https://github.com/WordPress/gutenberg/issues/8268 + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Foo' ); + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Bar' ); + + // Select text. + await page.keyboard.down( 'Shift' ); + await pressKeyTimes( 'ArrowLeft', 3 ); + await page.keyboard.up( 'Shift' ); + + // Delete selection. + await page.keyboard.press( 'Backspace' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should gracefully handle if placing caret in empty container', async () => { + // Regression Test: placeCaretAtHorizontalEdge previously did not + // account for contentEditables which have no children. + // + // See: https://github.com/WordPress/gutenberg/issues/8676 + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Foo' ); + + // The regression appeared to only affect paragraphs created while + // within an inline boundary. + await page.keyboard.down( 'Shift' ); + await pressKeyTimes( 'ArrowLeft', 3 ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should forward delete from an empty paragraph', async () => { + // Regression test: Bogus nodes in a RichText container can interfere + // with isHorizontalEdge detection, preventing forward deletion. + // + // See: https://github.com/WordPress/gutenberg/issues/8731 + await insertBlock( 'Paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Delete' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should remove empty paragraph block on backspace', async () => { + // Regression Test: In a sole empty paragraph, pressing backspace + // should remove the block. + // + // See: https://github.com/WordPress/gutenberg/pull/8306 + await insertBlock( 'Paragraph' ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should remove at most one paragraph in forward direction', async () => { + // Regression Test: A forward delete on empty RichText previously would + // destroy two paragraphs on the dual-action of merge & remove. + // + // See: https://github.com/WordPress/gutenberg/pull/8735 + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'First' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Delete' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should ensure always a default block', async () => { + // Feature: To avoid focus loss, removal of all blocks will result in a + // default block insertion at the root. Pressing backspace in a new + // paragraph should not effectively allow removal. This is counteracted + // with pre-save content processing to save post consisting of only the + // unmodified default block as an empty string. + // + // See: https://github.com/WordPress/gutenberg/issues/9626 + await insertBlock( 'Paragraph' ); + await page.keyboard.press( 'Backspace' ); + + // There is a default block: + expect( await page.$$( '.block-editor-block-list__block' ) ).toHaveLength( 1 ); + + // But the effective saved content is still empty: + expect( await getEditedPostContent() ).toBe( '' ); + + // And focus is retained: + expect( await isInDefaultBlock() ).toBe( true ); + } ); + + it( 'should undo split in one go', async () => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '12' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Enter' ); + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/style-variation.test.js b/gb-src/packages/e2e-tests/specs/style-variation.test.js new file mode 100644 index 0000000000000..7b073fe7b3f26 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/style-variation.test.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { + createNewPost, + clickBlockToolbarButton, + insertBlock, + getEditedPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'adding blocks', () => { + beforeAll( async () => { + await createNewPost(); + } ); + + it( 'Should switch the style of the quote block', async () => { + // Inserting a quote block + await insertBlock( 'Quote' ); + await page.keyboard.type( 'Quote content' ); + + await clickBlockToolbarButton( 'Change block type or style' ); + + const styleVariations = await page.$$( '.block-editor-block-styles__item' ); + await styleVariations[ 1 ].click(); + + // Check the content + const content = await getEditedPostContent(); + expect( content ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/taxonomies.test.js b/gb-src/packages/e2e-tests/specs/taxonomies.test.js new file mode 100644 index 0000000000000..791fd79b12f8d --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/taxonomies.test.js @@ -0,0 +1,177 @@ +/** + * WordPress dependencies + */ +import { + findSidebarPanelWithTitle, + createNewPost, + openDocumentSettingsSidebar, + publishPost, +} from '@wordpress/e2e-test-utils'; + +/** + * Module constants + */ +const TAG_TOKEN_SELECTOR = '.components-form-token-field__token-text span:not(.screen-reader-text)'; + +describe( 'Taxonomies', () => { + const canCreatTermInTaxonomy = ( taxonomy ) => { + return page.evaluate( + ( _taxonomy ) => { + const post = wp.data.select( 'core/editor' ).getCurrentPost(); + if ( ! post._links ) { + return false; + } + return !! post._links[ `wp:action-create-${ _taxonomy }` ]; + }, + taxonomy + ); + }; + + const getSelectCategories = () => { + return page.evaluate( + () => { + return Array.from( document.querySelectorAll( + '.editor-post-taxonomies__hierarchical-terms-choice .components-checkbox-control__input:checked' + ) ).map( ( node ) => { + return node.parentElement.nextSibling.innerText; + } ); + } + ); + }; + + const getCurrentTags = async () => { + const tagsPanel = await findSidebarPanelWithTitle( 'Tags' ); + return page.evaluate( ( node, selector ) => { + return Array.from( node.querySelectorAll( + selector + ) ).map( ( field ) => { + return field.innerText; + } ); + }, tagsPanel, TAG_TOKEN_SELECTOR ); + }; + + it( 'should be able to open the categories panel and create a new main category if the user has the right capabilities', async () => { + await createNewPost(); + + await openDocumentSettingsSidebar(); + + const categoriesPanel = await findSidebarPanelWithTitle( 'Categories' ); + expect( categoriesPanel ).toBeDefined(); + + // If the user has no permission to add a new category finish the test. + if ( ! ( await canCreatTermInTaxonomy( 'categories' ) ) ) { + return; + } + + // Open the categories panel. + await categoriesPanel.click( 'button' ); + + await page.waitForSelector( 'button.editor-post-taxonomies__hierarchical-terms-add' ); + + // Click add new category button. + await page.click( 'button.editor-post-taxonomies__hierarchical-terms-add' ); + + // Type the category name in the field. + await page.type( + '.editor-post-taxonomies__hierarchical-terms-input[type=text]', + 'z rand category 1' + ); + + // Click the submit button. + await page.click( '.editor-post-taxonomies__hierarchical-terms-submit' ); + + // Wait for the categories to load. + await page.waitForSelector( '.editor-post-taxonomies__hierarchical-terms-choice .components-checkbox-control__input:checked' ); + + let selectedCategories = await getSelectCategories(); + + // The new category is selected. + expect( selectedCategories ).toHaveLength( 1 ); + expect( selectedCategories[ 0 ] ).toEqual( 'z rand category 1' ); + + // Type something in the title so we can publish the post. + await page.type( '.editor-post-title__input', 'Hello World' ); + + // Publish the post. + await publishPost(); + + // Reload the editor. + await page.reload(); + + // Wait for the categories to load. + await page.waitForSelector( '.editor-post-taxonomies__hierarchical-terms-choice .components-checkbox-control__input:checked' ); + + selectedCategories = await getSelectCategories(); + + // The category selection was persisted after the publish process. + expect( selectedCategories ).toHaveLength( 1 ); + expect( selectedCategories[ 0 ] ).toEqual( 'z rand category 1' ); + } ); + + // This test isn't reliable locally because repeated execution of the test triggers 400 network + // because of the tag's duplication. Also, it randomly doesn't add a new tag after pressing enter. + // See: https://github.com/WordPress/gutenberg/pull/15211. + it.skip( 'should be able to open the tags panel and create a new tag if the user has the right capabilities', async () => { + await createNewPost(); + + await openDocumentSettingsSidebar(); + + const tagsPanel = await findSidebarPanelWithTitle( 'Tags' ); + + //expect( await page.evaluate( ( el ) => el.outerHTML, tagsPanel ) ).toEqual( 'tag1 ok' ); + expect( tagsPanel ).toBeDefined(); + + // If the user has no permission to add a new tag finish the test. + if ( ! ( await canCreatTermInTaxonomy( 'tags' ) ) ) { + return; + } + + // Open the tags panel. + await tagsPanel.click( 'button' ); + + // At the start there are no tag tokens + expect( + await page.$$( + TAG_TOKEN_SELECTOR + ) + ).toHaveLength( 0 ); + + const tagInput = await tagsPanel.$( '.components-form-token-field__input' ); + + // Click the tag input field. + await tagInput.click(); + + // Type the category name in the field. + await tagInput.type( 'tag1' ); + + // Press enter to create a new tag. + await tagInput.press( 'Enter' ); + + await page.waitForSelector( TAG_TOKEN_SELECTOR ); + + // Get an array with the tags of the post. + let tags = await getCurrentTags(); + + // The post should only contain the tag we added. + expect( tags ).toHaveLength( 1 ); + expect( tags[ 0 ] ).toEqual( 'tag1' ); + + // Type something in the title so we can publish the post. + await page.type( '.editor-post-title__input', 'Hello World' ); + + // Publish the post. + await publishPost(); + + // Reload the editor. + await page.reload(); + + // Wait for the tags to load. + await page.waitForSelector( '.components-form-token-field__token' ); + + tags = await getCurrentTags(); + + // The tag selection was persisted after the publish process. + expect( tags ).toHaveLength( 1 ); + expect( tags[ 0 ] ).toEqual( 'tag1' ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/typewriter.test.js b/gb-src/packages/e2e-tests/specs/typewriter.test.js new file mode 100644 index 0000000000000..98d54e17da1b7 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/typewriter.test.js @@ -0,0 +1,182 @@ +/** + * WordPress dependencies + */ +import { createNewPost } from '@wordpress/e2e-test-utils'; + +describe( 'TypeWriter', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + const getCaretPosition = async () => + await page.evaluate( () => wp.dom.computeCaretRect().y ); + + // Allow the scroll position to be 1px off. + const BUFFER = 1; + + const getDiff = async ( caretPosition ) => + Math.abs( await getCaretPosition() - caretPosition ); + + it( 'should maintain caret position', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + const initialPosition = await getCaretPosition(); + + // The page shouldn't be scrolled when it's being filled. + await page.keyboard.press( 'Enter' ); + + expect( await getCaretPosition() ).toBeGreaterThan( initialPosition ); + + // Create blocks until the the typewriter effect kicks in. + while ( await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop === 0 + ) ) { + await page.keyboard.press( 'Enter' ); + } + + const newPosition = await getCaretPosition(); + + // Now the scroll position should be maintained. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Type until the text wraps. + while ( await page.evaluate( () => + document.activeElement.clientHeight <= + parseInt( getComputedStyle( document.activeElement ).lineHeight, 10 ) + ) ) { + await page.keyboard.type( 'a' ); + } + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Pressing backspace will reposition the caret to the previous line. + // Scroll position should be adjusted again. + await page.keyboard.press( 'Backspace' ); + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Should reset scroll position to maintain. + await page.keyboard.press( 'ArrowUp' ); + + const positionAfterArrowUp = await getCaretPosition(); + + expect( positionAfterArrowUp ).toBeLessThanOrEqual( newPosition ); + + // Should be scrolled to new position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( positionAfterArrowUp ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should maintain caret position after scroll', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + // Create blocks until there is a scrollable container. + while ( await page.evaluate( () => + ! wp.dom.getScrollContainer( document.activeElement ) + ) ) { + await page.keyboard.press( 'Enter' ); + } + + await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop = 1 + ); + + const initialPosition = await getCaretPosition(); + + // Should maintain scroll position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( initialPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should maintain caret position after leaving last editable', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + // Create second block. + await page.keyboard.press( 'Enter' ); + // Move to first block. + await page.keyboard.press( 'ArrowUp' ); + + const initialPosition = await getCaretPosition(); + + // Should maintain scroll position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( initialPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should scroll caret into view from the top', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + // Create blocks until there is a scrollable container. + while ( await page.evaluate( () => + ! wp.dom.getScrollContainer( document.activeElement ) + ) ) { + await page.keyboard.press( 'Enter' ); + } + + let count = 0; + + // Create blocks until the the typewriter effect kicks in. + while ( await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop === 0 + ) ) { + await page.keyboard.press( 'Enter' ); + count++; + } + + // Scroll the active element to the very bottom of the scroll container, + // then scroll 20px down, so the caret is partially hidden. + await page.evaluate( () => { + document.activeElement.scrollIntoView( false ); + wp.dom.getScrollContainer( document.activeElement ).scrollTop -= 20; + } ); + + const bottomPostition = await getCaretPosition(); + + // Should scroll the caret back into view (preserve browser behaviour). + await page.keyboard.type( 'a' ); + + const newBottomPosition = await getCaretPosition(); + + expect( newBottomPosition ).toBeLessThanOrEqual( bottomPostition ); + + // Should maintain new caret position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newBottomPosition ) ).toBeLessThanOrEqual( BUFFER ); + + await page.keyboard.press( 'Backspace' ); + + while ( count-- ) { + await page.keyboard.press( 'ArrowUp' ); + } + + // Scroll the active element to the very top of the scroll container, + // then scroll 10px down, so the caret is partially hidden. + await page.evaluate( () => { + document.activeElement.scrollIntoView(); + wp.dom.getScrollContainer( document.activeElement ).scrollTop += 20; + } ); + + const topPostition = await getCaretPosition(); + + // Should scroll the caret back into view (preserve browser behaviour). + await page.keyboard.type( 'a' ); + + const newTopPosition = await getCaretPosition(); + + expect( newTopPosition ).toBeGreaterThan( topPostition ); + + // Should maintain new caret position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newTopPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/undo.test.js b/gb-src/packages/e2e-tests/specs/undo.test.js new file mode 100644 index 0000000000000..01b1336c5ceb8 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/undo.test.js @@ -0,0 +1,246 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + createNewPost, + pressKeyWithModifier, + selectBlockByClientId, + getAllBlocks, + saveDraft, + publishPost, + disableNavigationMode, +} from '@wordpress/e2e-test-utils'; + +describe( 'undo', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should undo typing after a pause', async () => { + await clickBlockAppender(); + + await page.keyboard.type( 'before pause' ); + await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); + await page.keyboard.type( ' after pause' ); + + const after = await getEditedPostContent(); + + expect( after ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + const before = await getEditedPostContent(); + + expect( before ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toBe( '' ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); + + expect( await getEditedPostContent() ).toBe( before ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); + + expect( await getEditedPostContent() ).toBe( after ); + } ); + + it( 'should undo typing after non input change', async () => { + await clickBlockAppender(); + + await page.keyboard.type( 'before keyboard ' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( 'after keyboard' ); + + const after = await getEditedPostContent(); + + expect( after ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + const before = await getEditedPostContent(); + + expect( before ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toBe( '' ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); + + expect( await getEditedPostContent() ).toBe( before ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); + + expect( await getEditedPostContent() ).toBe( after ); + } ); + + it( 'should undo bold', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'test' ); + await saveDraft(); + await page.reload(); + await page.click( '.wp-block-paragraph' ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'z' ); + + const visibleResult = await page.evaluate( () => document.activeElement.innerHTML ); + expect( visibleResult ).toBe( 'test' ); + } ); + + it( 'Should undo/redo to expected level intervals', async () => { + await clickBlockAppender(); + + const firstBlock = await getEditedPostContent(); + + await page.keyboard.type( 'This' ); + + const firstText = await getEditedPostContent(); + + await page.keyboard.press( 'Enter' ); + + const secondBlock = await getEditedPostContent(); + + await page.keyboard.type( 'is' ); + + const secondText = await getEditedPostContent(); + + await page.keyboard.press( 'Enter' ); + + const thirdBlock = await getEditedPostContent(); + + await page.keyboard.type( 'test' ); + + const thirdText = await getEditedPostContent(); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd paragraph text. + + expect( await getEditedPostContent() ).toBe( thirdBlock ); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd block. + + expect( await getEditedPostContent() ).toBe( secondText ); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. + + expect( await getEditedPostContent() ).toBe( secondBlock ); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd block. + + expect( await getEditedPostContent() ).toBe( firstText ); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. + + expect( await getEditedPostContent() ).toBe( firstBlock ); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st block. + + expect( await getEditedPostContent() ).toBe( '' ); + // After undoing every action, there should be no more undo history. + expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).not.toBeNull(); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st block. + + expect( await getEditedPostContent() ).toBe( firstBlock ); + // After redoing one change, the undo button should be enabled again. + expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).toBeNull(); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st paragraph text. + + expect( await getEditedPostContent() ).toBe( firstText ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd block. + + expect( await getEditedPostContent() ).toBe( secondBlock ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd paragraph text. + + expect( await getEditedPostContent() ).toBe( secondText ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd block. + + expect( await getEditedPostContent() ).toBe( thirdBlock ); + + await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd paragraph text. + + expect( await getEditedPostContent() ).toBe( thirdText ); + } ); + + it( 'should undo for explicit persistence editing post', async () => { + // Regression test: An issue had occurred where the creation of an + // explicit undo level would interfere with blocks values being synced + // correctly to the block editor. + // + // See: https://github.com/WordPress/gutenberg/issues/14950 + + // Issue is demonstrated from an edited post: create, save, and reload. + await clickBlockAppender(); + await page.keyboard.type( 'original' ); + await saveDraft(); + await page.reload(); + await disableNavigationMode(); + + // Issue is demonstrated by forcing state merges (multiple inputs) on + // an existing text after a fresh reload. + await selectBlockByClientId( ( await getAllBlocks() )[ 0 ].clientId ); + await page.keyboard.type( 'modified' ); + + // The issue is demonstrated after the one second delay to trigger the + // creation of an explicit undo persistence level. + await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); + + await pressKeyWithModifier( 'primary', 'z' ); + + // Assert against the _visible_ content. Since editor state with the + // regression present was accurate, it would produce the correct + // content. The issue had manifested in the form of what was shown to + // the user since the blocks state failed to sync to block editor. + const visibleContent = await page.evaluate( () => document.activeElement.textContent ); + expect( visibleContent ).toBe( 'original' ); + } ); + + it( 'should not create undo levels when saving', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await saveDraft(); + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toBe( '' ); + } ); + + it( 'should not create undo levels when publishing', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await publishPost(); + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toBe( '' ); + } ); + + it( 'should immediately create an undo level on typing', async () => { + await clickBlockAppender(); + + await page.keyboard.type( '1' ); + await saveDraft(); + await page.reload(); + + // Expect undo button to be disabled. + expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).not.toBeNull(); + + await page.click( '.wp-block-paragraph' ); + + await page.keyboard.type( '2' ); + + // Expect undo button to be enabled. + expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).toBeNull(); + + await pressKeyWithModifier( 'primary', 'z' ); + + // Expect "1". + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/packages/e2e-tests/specs/writing-flow.test.js b/gb-src/packages/e2e-tests/specs/writing-flow.test.js new file mode 100644 index 0000000000000..f0683ff51e0f0 --- /dev/null +++ b/gb-src/packages/e2e-tests/specs/writing-flow.test.js @@ -0,0 +1,425 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + createNewPost, + pressKeyTimes, + pressKeyWithModifier, + insertBlock, +} from '@wordpress/e2e-test-utils'; + +describe( 'Writing Flow', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Should navigate inner blocks with arrow keys', async () => { + // TODO: The `waitForSelector` calls in this function should ultimately + // not be necessary for interactions, and exist as a stop-gap solution + // where rendering delays in slower CPU can cause intermittent failure. + + let activeElementText; + + // Add demo content + await clickBlockAppender(); + await page.keyboard.type( 'First paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/columns' ); + await page.keyboard.press( 'Enter' ); + await page.click( ':focus [aria-label="Two columns; equal split"]' ); + await page.click( ':focus .block-editor-button-block-appender' ); + await page.waitForSelector( ':focus.block-editor-inserter__search' ); + await page.keyboard.type( 'Paragraph' ); + await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result. + await page.keyboard.press( 'Enter' ); // Insert paragraph. + await page.keyboard.type( '1st col' ); // If this text is too long, it may wrap to a new line and cause test failure. That's why we're using "1st" instead of "First" here. + + // TODO: ArrowDown should traverse into the second column. In slower + // CPUs, it can sometimes remain in the first column paragraph. This + // is a temporary solution. + await page.focus( '.wp-block[data-type="core/column"]:nth-child(2)' ); + await page.click( ':focus .block-editor-button-block-appender' ); + await page.waitForSelector( ':focus.block-editor-inserter__search' ); + await page.keyboard.type( 'Paragraph' ); + await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result. + await page.keyboard.press( 'Enter' ); // Insert paragraph. + await page.keyboard.type( '2nd col' ); // If this text is too long, it may wrap to a new line and cause test failure. That's why we're using "2nd" instead of "Second" here. + + // Arrow down from last of layouts exits nested context to default + // appender of root level. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.type( 'Second paragraph' ); + + // Arrow up into nested context focuses last text input + await page.keyboard.press( 'ArrowUp' ); + activeElementText = await page.evaluate( () => document.activeElement.textContent ); + expect( activeElementText ).toBe( '2nd col' ); + + // Arrow up in inner blocks should navigate through (1) column wrapper, + // (2) text fields. + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'ArrowUp' ); + activeElementText = await page.evaluate( () => document.activeElement.textContent ); + expect( activeElementText ).toBe( '1st col' ); + + // Arrow up from first text field in nested context focuses column and + // columns wrappers before escaping out. + let activeElementBlockType; + await page.keyboard.press( 'ArrowUp' ); + activeElementBlockType = await page.evaluate( () => ( + document.activeElement.getAttribute( 'data-type' ) + ) ); + expect( activeElementBlockType ).toBe( 'core/column' ); + await page.keyboard.press( 'ArrowUp' ); + activeElementBlockType = await page.evaluate( () => ( + document.activeElement.getAttribute( 'data-type' ) + ) ); + expect( activeElementBlockType ).toBe( 'core/columns' ); + + // Arrow up from focused (columns) block wrapper exits nested context + // to prior text input. + await page.keyboard.press( 'ArrowUp' ); + activeElementText = await page.evaluate( () => document.activeElement.textContent ); + expect( activeElementText ).toBe( 'First paragraph' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should navigate around inline boundaries', async () => { + // Add demo content + await clickBlockAppender(); + await page.keyboard.type( 'First' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Third' ); + + // Navigate to second paragraph + await pressKeyTimes( 'ArrowLeft', 6 ); + + // Bold second paragraph text + await page.keyboard.down( 'Shift' ); + await pressKeyTimes( 'ArrowLeft', 6 ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'b' ); + + // Arrow left from selected bold should collapse to before the inline + // boundary. Arrow once more to traverse into first paragraph. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( 'After' ); + + // Arrow right from end of first should traverse to second, *BEFORE* + // the bolded text. Another press should move within inline boundary. + await pressKeyTimes( 'ArrowRight', 2 ); + await page.keyboard.type( 'Inside' ); + + // Arrow left from end of beginning of inline boundary should move to + // the outside of the inline boundary. + await pressKeyTimes( 'ArrowLeft', 6 ); + await page.keyboard.press( 'ArrowLeft' ); // Separate for emphasis. + await page.keyboard.type( 'Before' ); + + // Likewise, test at the end of the inline boundary for same effect. + await page.keyboard.press( 'ArrowRight' ); // Move inside + await pressKeyTimes( 'ArrowRight', 12 ); + await page.keyboard.type( 'Inside' ); + await page.keyboard.press( 'ArrowRight' ); + + // Edge case: Verify that workaround to test for ZWSP at beginning of + // focus node does not take effect when on the right edge of inline + // boundary (thus preventing traversing to the next block by arrow). + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Should be after the inline boundary again. + await page.keyboard.type( 'After' ); + + // Finally, ensure that ArrowRight from end of unbolded text moves to + // the last paragraph + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'Before' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should navigate around nested inline boundaries', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '1 2' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'i' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.up( 'Shift' ); + await pressKeyWithModifier( 'primary', 'i' ); + await page.keyboard.press( 'ArrowLeft' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.type( 'a' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'b' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'c' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'd' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'e' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'f' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'g' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'h' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'i' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'j' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break at end', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break at end and continue writing', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + await page.keyboard.type( 'b' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break mid text', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break at start', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break in empty container', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not create extra line breaks in multiline value', async () => { + await insertBlock( 'Quote' ); + await page.keyboard.type( 'a' ); + await page.keyboard.press( 'Backspace' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should navigate native inputs vertically, not horizontally', async () => { + // See: https://github.com/WordPress/gutenberg/issues/9626 + + // Title is within the editor's writing flow, and is a ' ); + } ); + + it( 'should render textarea children as its content', () => { + const result = renderNativeComponent( 'textarea', { children: [ 'Hello' ] } ); + + expect( result ).toBe( '' ); + } ); + } ); + + describe( 'escaping', () => { + it( 'should escape children', () => { + const result = renderNativeComponent( 'div', { children: [ '' ] } ); + + expect( result ).toBe( '
<img/>
' ); + } ); + + it( 'should not render invalid dangerouslySetInnerHTML', () => { + const result = renderNativeComponent( 'div', { dangerouslySetInnerHTML: { __html: undefined } } ); + + expect( result ).toBe( '
' ); + } ); + + it( 'should not escape children with dangerouslySetInnerHTML', () => { + const result = renderNativeComponent( 'div', { dangerouslySetInnerHTML: { __html: '' } } ); + + expect( result ).toBe( '
' ); + } ); + } ); + + describe( 'self-closing', () => { + it( 'should render self-closing elements', () => { + const result = renderNativeComponent( 'img', { src: 'foo.png' } ); + + expect( result ).toBe( '' ); + } ); + + it( 'should ignore self-closing elements children', () => { + const result = renderNativeComponent( 'img', { src: 'foo.png', children: [ 'Surprise!' ] } ); + + expect( result ).toBe( '' ); + } ); + } ); + + describe( 'with children', () => { + it( 'should render single literal child', () => { + const result = renderNativeComponent( 'div', { children: 'Hello' } ); + + expect( result ).toBe( '
Hello
' ); + } ); + + it( 'should render array of children', () => { + const result = renderNativeComponent( 'div', { children: [ + 'Hello ', + World, + ] } ); + + expect( result ).toBe( '
Hello World
' ); + } ); + } ); +} ); + +describe( 'renderComponent()', () => { + it( 'calls constructor', () => { + class Example extends Component { + constructor() { + super( ...arguments ); + + this.constructed = 'constructed'; + } + + render() { + return this.constructed; + } + } + + const result = renderComponent( Example, {} ); + + expect( result ).toBe( 'constructed' ); + } ); + + it( 'does not call componentDidMount', () => { + class Example extends Component { + constructor() { + super( ...arguments ); + + this.state = {}; + } + + componentDidMount() { + this.setState( { didMounted: 'didMounted' } ); + } + + render() { + return this.state.didMounted; + } + } + + const result = renderComponent( Example, {} ); + + expect( result ).toBe( '' ); + } ); +} ); + +describe( 'renderAttributes()', () => { + describe( 'boolean attributes', () => { + it( 'should return boolean attributes false as omitted', () => { + const result = renderAttributes( { controls: false } ); + + expect( result ).toBe( '' ); + } ); + + it( 'should return boolean attributes non-false as present', () => { + [ true, 'true', 'false', '' ].forEach( ( controls ) => { + const result = renderAttributes( { controls } ); + + expect( result ).toBe( ' controls' ); + } ); + } ); + + it( 'should consider normalized boolean attribute name', () => { + const result = renderAttributes( { allowFullscreen: true } ); + + expect( result ).toBe( ' allowfullscreen' ); + } ); + } ); + + describe( 'prefixed attributes', () => { + it( 'should not render if nullish', () => { + [ null, undefined ].forEach( ( value ) => { + const result = renderAttributes( { 'data-foo': value } ); + + expect( result ).toBe( '' ); + } ); + } ); + + it( 'should return in its string form unmodified', () => { + let result = renderAttributes( { + 'aria-hidden': '', + } ); + + expect( result ).toBe( ' aria-hidden=""' ); + + result = renderAttributes( { + 'aria-hidden': true, + } ); + + expect( result ).toBe( ' aria-hidden="true"' ); + + result = renderAttributes( { + 'aria-hidden': false, + } ); + + expect( result ).toBe( ' aria-hidden="false"' ); + } ); + } ); + + describe( 'normalized attribute names', () => { + it( 'should return with normal attribute names', () => { + const result = renderAttributes( { + htmlFor: 'foo', + className: 'bar', + contentEditable: true, + } ); + + expect( result ).toBe( ' for="foo" class="bar" contenteditable="true"' ); + } ); + } ); + + describe( 'string escaping', () => { + it( 'should escape string attributes', () => { + const result = renderAttributes( { + style: { + background: 'url("foo.png")', + }, + href: '/index.php?foo=bar&qux=<"scary">', + } ); + + expect( result ).toBe( ' style="background:url("foo.png")" href="/index.php?foo=bar&qux=<"scary">"' ); + } ); + + it( 'should render numeric attributes', () => { + const result = renderAttributes( { + size: 10, + } ); + + expect( result ).toBe( ' size="10"' ); + } ); + } ); + + describe( 'ignored attributes', () => { + it( 'does not render nullish attributes', () => { + const result = renderAttributes( { + className: null, + htmlFor: undefined, + } ); + + expect( result ).toBe( '' ); + } ); + + it( 'does not render attributes of invalid types', () => { + const result = renderAttributes( { + onClick: () => {}, + className: [], + } ); + + expect( result ).toBe( '' ); + } ); + + it( 'does not render internal attributes', () => { + const result = renderAttributes( { + key: 'foo', + children: [ 'hello' ], + } ); + + expect( result ).toBe( '' ); + } ); + } ); +} ); + +describe( 'renderStyle()', () => { + it( 'should return string verbatim', () => { + const result = renderStyle( 'color:red' ); + + expect( result ).toBe( 'color:red' ); + } ); + + it( 'should return undefined if empty', () => { + const result = renderStyle( {} ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should render without trailing semi-colon', () => { + const result = renderStyle( { + color: 'red', + } ); + + expect( result ).toBe( 'color:red' ); + } ); + + it( 'should not render nullish value', () => { + const result = renderStyle( { + border: null, + backgroundColor: undefined, + color: 'red', + } ); + + expect( result ).toBe( 'color:red' ); + } ); + + it( 'should render a semi-colon delimited set', () => { + const result = renderStyle( { + color: 'red', + border: '1px dotted green', + } ); + + expect( result ).toBe( 'color:red;border:1px dotted green' ); + } ); + + it( 'should kebab-case style properties', () => { + const result = renderStyle( { + color: 'red', + backgroundColor: 'green', + } ); + + expect( result ).toBe( 'color:red;background-color:green' ); + } ); + + it( 'should not kebab-case custom properties', () => { + const result = renderStyle( { + '--myBackgroundColor': 'palegoldenrod', + } ); + + expect( result ).toBe( '--myBackgroundColor:palegoldenrod' ); + } ); + + it( 'should -kebab-case style properties with a vendor prefix', () => { + const result = renderStyle( { + msTransform: 'none', + OTransform: 'none', + MozTransform: 'none', + WebkitTransform: 'none', + } ); + + expect( result ).toBe( '-ms-transform:none;-o-transform:none;-moz-transform:none;-webkit-transform:none' ); + } ); + + describe( 'value unit', () => { + it( 'should not render zero unit', () => { + const result = renderStyle( { + borderWidth: 0, + } ); + + expect( result ).toBe( 'border-width:0' ); + } ); + + it( 'should render numeric units', () => { + const result = renderStyle( { + borderWidth: 10, + } ); + + expect( result ).toBe( 'border-width:10px' ); + } ); + + it( 'should not render numeric units for unitless properties', () => { + const result = renderStyle( { + order: 10, + } ); + + expect( result ).toBe( 'order:10' ); + } ); + } ); +} ); diff --git a/gb-src/packages/element/src/test/utils.js b/gb-src/packages/element/src/test/utils.js new file mode 100644 index 0000000000000..e9f5f744e01fe --- /dev/null +++ b/gb-src/packages/element/src/test/utils.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { createElement } from '../react'; +import { isEmptyElement } from '../utils'; + +describe( 'isEmptyElement', () => { + test( 'should be empty', () => { + expect( isEmptyElement( undefined ) ).toBe( true ); + expect( isEmptyElement( false ) ).toBe( true ); + expect( isEmptyElement( '' ) ).toBe( true ); + expect( isEmptyElement( new String( '' ) ) ).toBe( true ); + expect( isEmptyElement( [] ) ).toBe( true ); + } ); + + test( 'should not be empty', () => { + expect( isEmptyElement( 0 ) ).toBe( false ); + expect( isEmptyElement( 100 ) ).toBe( false ); + expect( isEmptyElement( 'test' ) ).toBe( false ); + expect( isEmptyElement( createElement( 'div' ) ) ).toBe( false ); + expect( isEmptyElement( [ 'x' ] ) ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/element/src/utils.js b/gb-src/packages/element/src/utils.js new file mode 100644 index 0000000000000..d7149b943535d --- /dev/null +++ b/gb-src/packages/element/src/utils.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { + isArray, + isNumber, + isString, +} from 'lodash'; + +/** + * Checks if the provided WP element is empty. + * + * @param {*} element WP element to check. + * @return {boolean} True when an element is considered empty. + */ +export const isEmptyElement = ( element ) => { + if ( isNumber( element ) ) { + return false; + } + + if ( isString( element ) || isArray( element ) ) { + return ! element.length; + } + + return ! element; +}; diff --git a/gb-src/packages/escape-html/.npmrc b/gb-src/packages/escape-html/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/escape-html/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/escape-html/CHANGELOG.md b/gb-src/packages/escape-html/CHANGELOG.md new file mode 100644 index 0000000000000..9f38d3c2bb14e --- /dev/null +++ b/gb-src/packages/escape-html/CHANGELOG.md @@ -0,0 +1,9 @@ +## 1.2.0 (2019-03-20) + +- Add fix for WordPress wptexturize greater-than tokenize bug (see https://core.trac.wordpress.org/ticket/45387) + +## 1.0.1 (2018-10-19) + +## 1.0.0 (2018-10-18) + +- Initial release. diff --git a/gb-src/packages/escape-html/README.md b/gb-src/packages/escape-html/README.md new file mode 100644 index 0000000000000..764a6052d7e79 --- /dev/null +++ b/gb-src/packages/escape-html/README.md @@ -0,0 +1,125 @@ +# Escape HTML + +Escape HTML utils. + +## Installation + +Install the module + +```bash +npm install @wordpress/escape-html +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + + + +# **escapeAmpersand** + +Returns a string with ampersands escaped. Note that this is an imperfect +implementation, where only ampersands which do not appear as a pattern of +named, decimal, or hexadecimal character references are escaped. Invalid +named references (i.e. ambiguous ampersand) are are still permitted. + +_Related_ + +- +- +- + +_Parameters_ + +- _value_ `string`: Original string. + +_Returns_ + +- `string`: Escaped string. + +# **escapeAttribute** + +Returns an escaped attribute value. + +_Related_ + +- + +"[...] the text cannot contain an ambiguous ampersand [...] must not contain +any literal U+0022 QUOTATION MARK characters (")" + +Note we also escape the greater than symbol, as this is used by wptexturize to +split HTML strings. This is a WordPress specific fix + +Note that if a resolution for Trac#45387 comes to fruition, it is no longer +necessary for `__unstableEscapeGreaterThan` to be used. + +See: + +_Parameters_ + +- _value_ `string`: Attribute value. + +_Returns_ + +- `string`: Escaped attribute value. + +# **escapeHTML** + +Returns an escaped HTML element value. + +_Related_ + +- + +"the text must not contain the character U+003C LESS-THAN SIGN (\<) or an +ambiguous ampersand." + +_Parameters_ + +- _value_ `string`: Element value. + +_Returns_ + +- `string`: Escaped HTML element value. + +# **escapeLessThan** + +Returns a string with less-than sign replaced. + +_Parameters_ + +- _value_ `string`: Original string. + +_Returns_ + +- `string`: Escaped string. + +# **escapeQuotationMark** + +Returns a string with quotation marks replaced. + +_Parameters_ + +- _value_ `string`: Original string. + +_Returns_ + +- `string`: Escaped string. + +# **isValidAttributeName** + +Returns true if the given attribute name is valid, or false otherwise. + +_Parameters_ + +- _name_ `string`: Attribute name to test. + +_Returns_ + +- `boolean`: Whether attribute is valid. + + + + +

Code is Poetry.

diff --git a/gb-src/packages/escape-html/package.json b/gb-src/packages/escape-html/package.json new file mode 100644 index 0000000000000..19d4e1d0b895c --- /dev/null +++ b/gb-src/packages/escape-html/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wordpress/escape-html", + "version": "1.5.1", + "description": "Escape HTML utils.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/escape-html/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/escape-html" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/escape-html/src/escape-greater.js b/gb-src/packages/escape-html/src/escape-greater.js new file mode 100644 index 0000000000000..f761a81e16ae6 --- /dev/null +++ b/gb-src/packages/escape-html/src/escape-greater.js @@ -0,0 +1,15 @@ +/** + * Returns a string with greater-than sign replaced. + * + * Note that if a resolution for Trac#45387 comes to fruition, it is no longer + * necessary for `__unstableEscapeGreaterThan` to exist. + * + * See: https://core.trac.wordpress.org/ticket/45387 + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export default function __unstableEscapeGreaterThan( value ) { + return value.replace( />/g, '>' ); +} diff --git a/gb-src/packages/escape-html/src/index.js b/gb-src/packages/escape-html/src/index.js new file mode 100644 index 0000000000000..d31f43f63f70b --- /dev/null +++ b/gb-src/packages/escape-html/src/index.js @@ -0,0 +1,108 @@ +/** + * Internal dependencies + */ +import __unstableEscapeGreaterThan from './escape-greater'; + +/** + * Regular expression matching invalid attribute names. + * + * "Attribute names must consist of one or more characters other than controls, + * U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=), + * and noncharacters." + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 + * + * @type {RegExp} + */ +const REGEXP_INVALID_ATTRIBUTE_NAME = /[\u007F-\u009F "'>/="\uFDD0-\uFDEF]/; + +/** + * Returns a string with ampersands escaped. Note that this is an imperfect + * implementation, where only ampersands which do not appear as a pattern of + * named, decimal, or hexadecimal character references are escaped. Invalid + * named references (i.e. ambiguous ampersand) are are still permitted. + * + * @see https://w3c.github.io/html/syntax.html#character-references + * @see https://w3c.github.io/html/syntax.html#ambiguous-ampersand + * @see https://w3c.github.io/html/syntax.html#named-character-references + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeAmpersand( value ) { + return value.replace( /&(?!([a-z0-9]+|#[0-9]+|#x[a-f0-9]+);)/gi, '&' ); +} + +/** + * Returns a string with quotation marks replaced. + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeQuotationMark( value ) { + return value.replace( /"/g, '"' ); +} + +/** + * Returns a string with less-than sign replaced. + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeLessThan( value ) { + return value.replace( / { + const result = implementation( 'Chicken > Ribs' ); + expect( result ).toBe( 'Chicken > Ribs' ); + } ); +} + +function testEscapeAmpersand( implementation ) { + it( 'should escape ampersand', () => { + const result = implementation( 'foo & bar & & baz Σ &#bad; Σ Σ vil;' ); + + expect( result ).toBe( 'foo & bar & & baz Σ &#bad; Σ Σ &#xevil;' ); + } ); +} + +function testEscapeQuotationMark( implementation ) { + it( 'should escape quotation mark', () => { + const result = implementation( '"Be gone!"' ); + + expect( result ).toBe( '"Be gone!"' ); + } ); +} + +function testEscapeLessThan( implementation ) { + it( 'should escape less than', () => { + const result = implementation( 'Chicken < Ribs' ); + + expect( result ).toBe( 'Chicken < Ribs' ); + } ); +} + +describe( 'escapeAmpersand', () => { + testEscapeAmpersand( escapeAmpersand ); +} ); + +describe( 'escapeQuotationMark', () => { + testEscapeQuotationMark( escapeQuotationMark ); +} ); + +describe( 'escapeLessThan', () => { + testEscapeLessThan( escapeLessThan ); +} ); + +describe( 'escapeGreaterThan', () => { + testUnstableEscapeGreaterThan( __unstableEscapeGreaterThan ); +} ); + +describe( 'escapeAttribute', () => { + testEscapeAmpersand( escapeAttribute ); + testEscapeQuotationMark( escapeAttribute ); + testUnstableEscapeGreaterThan( escapeAttribute ); +} ); + +describe( 'escapeHTML', () => { + testEscapeAmpersand( escapeHTML ); + testEscapeLessThan( escapeHTML ); +} ); + +describe( 'isValidAttributeName', () => { + it( 'should return false for attribute with controls', () => { + const result = isValidAttributeName( 'bad\u007F' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for attribute with non-permitted characters', () => { + const result = isValidAttributeName( 'bad"' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for attribute with noncharacters', () => { + const result = isValidAttributeName( 'bad\uFDD0' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true for valid attribute name', () => { + const result = isValidAttributeName( 'good' ); + + expect( result ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/eslint-plugin/.npmrc b/gb-src/packages/eslint-plugin/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/eslint-plugin/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/eslint-plugin/CHANGELOG.md b/gb-src/packages/eslint-plugin/CHANGELOG.md new file mode 100644 index 0000000000000..09d7afafa006a --- /dev/null +++ b/gb-src/packages/eslint-plugin/CHANGELOG.md @@ -0,0 +1,72 @@ +## 3.0.0 (2019-08-29) + +### Breaking Changes + +- The [`@wordpress/no-unused-vars-before-return` rule](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) has been improved to exempt object destructuring only if destructuring to more than one property. +- Stricter JSDoc linting using [`eslint-plugin-jsdoc`](https://github.com/gajus/eslint-plugin-jsdoc). +- Stricter validation enabled for test files only using new `test-e2e` and `test-unit` rulesets. + +### New Features + +- New Rule: [`@wordpress/no-unguarded-get-range-at`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) +- Enable `wp` global by default in the `recommended` config. +- New ruleset `test-e2e` added for end-to-end tests validation. +- New ruleset `test-unit` added for unit tests validation. + +### Enhancements + +- Remove `@wordpress/dependency-group` and `@wordpress/gutenberg-phase` rules from the `custom` and `recommended` configs and leave them as opt-in features. + +## 2.4.0 (2019-08-05) + +### New Features + +- [`@wordpress/no-unused-vars-before-return`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) now supports an `excludePattern` option to exempt function calls by name. + +### Improvements + +- The recommended `react` configuration specifies an option to [`@wordpress/no-unused-vars-before-return`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) to exempt React hooks usage, by convention of hooks beginning with "use" prefix. +- The plugin now uses [`eslint-plugin-jsdoc`](https://github.com/gajus/eslint-plugin-jsdoc), rather than the `valid-jsdoc` rule, for more reliable linting of JSDoc blocks. + +## 2.3.0 (2019-06-12) + +### Bug Fix + +- Fixed custom regular expression for the `no-restricted-syntax` rule enforcing translate function arguments. [#15839](https://github.com/WordPress/gutenberg/pull/15839). +- Fixed arguments checking of `_nx` for the `no-restricted-syntax` rule enforcing translate function arguments. [#15839](https://github.com/WordPress/gutenberg/pull/15839). +- Fixed false positive with `react-no-unsafe-timeout` which would wrongly flag errors when assigning `setTimeout` result to a variable (for example, in a `useEffect` hook). + +## 2.2.0 (2019-05-21) + +### New Features + +- New Rule: [`@wordpress/react-no-unsafe-timeout`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md) +- Add [React Hooks Rules](https://reactjs.org/docs/hooks-rules.html) config. + +## 2.1.0 (2019-03-20) + +### New Features + +- The bundled `eslint-plugin-jsx-a11y` dependency has been updated from requiring `^6.0.2` to requiring `^6.2.1` (see new features added in [6.2.0](https://github.com/evcohen/eslint-plugin-jsx-a11y/releases/tag/v6.2.0) and [6.1.0](https://github.com/evcohen/eslint-plugin-jsx-a11y/releases/tag/v6.1.0)). +- The bundled `eslint-plugin-react` dependency has been updated from requiring `7.7.0` to requiring `^7.12.4` (see new features added in [7.12.0](https://github.com/yannickcr/eslint-plugin-react/releases/tag/v7.12.0), [7.11.0](https://github.com/yannickcr/eslint-plugin-react/releases/tag/v7.11.0), [7.10.0](https://github.com/yannickcr/eslint-plugin-react/releases/tag/v7.10.0), [7.9.0](https://github.com/yannickcr/eslint-plugin-react/releases/tag/v7.9.0) and [7.8.0](https://github.com/yannickcr/eslint-plugin-react/releases/tag/v7.8.0)). + +## 2.0.0 (2019-03-06) + +### Breaking Changes + +- The `esnext` and `recommended` rulesets now enforce [`object-shorthand`](https://eslint.org/docs/rules/object-shorthand) +- The `es5` and `recommended` rulesets now enforce [`array-callback-return`](https://eslint.org/docs/rules/array-callback-return) + +### New Features + +- New Rule: [`@wordpress/no-unused-vars-before-return`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) +- New Rule: [`@wordpress/dependency-group`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/dependency-group.md) +- New Rule: [`@wordpress/valid-sprintf`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/valid-sprintf.md) +- New Rule: [`@wordpress/gutenberg-phase`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/gutenberg-phase.md) +- New Rule: [`@wordpress/no-base-control-with-label-without-id`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) + +## 1.0.0 (2018-12-12) + +### New Features + +- Initial release. diff --git a/gb-src/packages/eslint-plugin/README.md b/gb-src/packages/eslint-plugin/README.md new file mode 100644 index 0000000000000..63034c7efe800 --- /dev/null +++ b/gb-src/packages/eslint-plugin/README.md @@ -0,0 +1,75 @@ +# ESLint Plugin + +[ESLint](https://eslint.org/) plugin including configurations and custom rules for WordPress development. + +## Installation + +Install the module + +```bash +npm install @wordpress/eslint-plugin --save-dev +``` + +## Usage + +To opt-in to the default configuration, extend your own project's `.eslintrc` file: + +```json +{ + "extends": [ "plugin:@wordpress/eslint-plugin/recommended" ] +} +``` + +Refer to the [ESLint documentation on Shareable Configs](http://eslint.org/docs/developer-guide/shareable-configs) for more information. + +The `recommended` preset will include rules governing an ES2015+ environment, and includes rules from the [`eslint-plugin-jsx-a11y`](https://github.com/evcohen/eslint-plugin-jsx-a11y) and [`eslint-plugin-react`](https://github.com/yannickcr/eslint-plugin-react) projects. + +### Rulesets + +Alternatively, you can opt-in to only the more granular rulesets offered by the plugin. These include: + +- `custom` +- `es5` +- `esnext` +- `jsdoc` +- `jshint` (legacy) +- `jsx-a11y` +- `react` +- `test-e2e` +- `test-unit` + +For example, if your project does not use React, you could consider extending including only the ESNext rules in your project using the following `extends` definition: + +```json +{ + "extends": [ "plugin:@wordpress/eslint-plugin/esnext" ] +} +``` + +These rules can be used additively, so you could extend both `esnext` and `custom` rulesets, but omit the `react` and `jsx-a11y` configurations. + +The granular rulesets will not define any environment globals. As such, if they are required for your project, you will need to define them yourself. + +### Rules + +Rule|Description|Recommended +---|---|--- +[dependency-group](/packages/eslint-plugin/docs/rules/dependency-group.md)|Enforce dependencies docblocks formatting|✓ +[gutenberg-phase](docs/rules/gutenberg-phase.md)|Governs the use of the `process.env.GUTENBERG_PHASE` constant|✓ +[no-unused-vars-before-return](/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md)|Disallow assigning variable values if unused before a return|✓ +[react-no-unsafe-timeout](/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md)|Disallow unsafe `setTimeout` in component| +[valid-sprintf](/packages/eslint-plugin/docs/rules/valid-sprintf.md)|Enforce valid sprintf usage|✓ +[no-base-control-with-label-without-id](/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md)| Disallow the usage of BaseControl component with a label prop set but omitting the id property|✓ +[no-unguarded-get-range-at](/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md)| Disallow the usage of unguarded `getRangeAt` calls|✓ + +### Legacy + +If you are using WordPress' `.jshintrc` JSHint configuration and you would like to take the first step to migrate to an ESLint equivalent it is also possible to define your own project's `.eslintrc` file as: + +```json +{ + "extends": [ "plugin:@wordpress/eslint-plugin/jshint" ] +} +``` + +

Code is Poetry.

diff --git a/gb-src/packages/eslint-plugin/configs/custom.js b/gb-src/packages/eslint-plugin/configs/custom.js new file mode 100644 index 0000000000000..69ca991b7e56b --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/custom.js @@ -0,0 +1,39 @@ +module.exports = { + plugins: [ + '@wordpress', + ], + rules: { + '@wordpress/no-unused-vars-before-return': 'error', + '@wordpress/valid-sprintf': 'error', + '@wordpress/no-base-control-with-label-without-id': 'error', + '@wordpress/no-unguarded-get-range-at': 'error', + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.name=/^(__|_n|_nx|_x)$/]:not([arguments.0.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + { + selector: 'CallExpression[callee.name=/^(_n|_nx|_x)$/]:not([arguments.1.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + { + selector: 'CallExpression[callee.name=_nx]:not([arguments.3.type=/^Literal|BinaryExpression$/])', + message: 'Translate function arguments must be string literals.', + }, + ], + }, + overrides: [ + { + files: [ '*.native.js' ], + rules: { + '@wordpress/no-base-control-with-label-without-id': 'off', + }, + }, + ], + settings: { + react: { + version: '16.6', + }, + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/es5.js b/gb-src/packages/eslint-plugin/configs/es5.js new file mode 100644 index 0000000000000..7ed0b7ff9d072 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/es5.js @@ -0,0 +1,83 @@ +module.exports = { + extends: [ + require.resolve( './jsdoc.js' ), + ], + rules: { + 'array-bracket-spacing': [ 'error', 'always' ], + 'array-callback-return': 'error', + 'brace-style': [ 'error', '1tbs' ], + camelcase: [ 'error', { + properties: 'never', + } ], + 'comma-dangle': [ 'error', 'always-multiline' ], + 'comma-spacing': 'error', + 'comma-style': [ 'error', 'last' ], + curly: [ 'error', 'all' ], + 'dot-notation': 'error', + 'eol-last': 'error', + eqeqeq: 'error', + 'func-call-spacing': 'error', + indent: [ 'error', 'tab', { SwitchCase: 1 } ], + 'key-spacing': 'error', + 'keyword-spacing': 'error', + 'linebreak-style': [ 'error', 'unix' ], + 'no-alert': 'error', + 'no-bitwise': 'error', + 'no-caller': 'error', + 'no-cond-assign': [ 'error', 'except-parens' ], + 'no-console': 'error', + 'no-debugger': 'error', + 'no-dupe-args': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-else-return': 'error', + 'no-eval': 'error', + 'no-extra-semi': 'error', + 'no-fallthrough': 'error', + 'no-irregular-whitespace': 'error', + 'no-lonely-if': 'error', + 'no-multi-str': 'error', + 'no-mixed-operators': 'error', + 'no-mixed-spaces-and-tabs': 'error', + 'no-multiple-empty-lines': [ 'error', { max: 1 } ], + 'no-multi-spaces': 'error', + 'no-negated-in-lhs': 'error', + 'no-nested-ternary': 'error', + 'no-redeclare': 'error', + 'no-shadow': 'error', + 'no-trailing-spaces': 'error', + 'no-undef': 'error', + 'no-undef-init': 'error', + 'no-unreachable': 'error', + 'no-unsafe-negation': 'error', + 'no-unused-expressions': 'error', + 'no-unused-vars': 'error', + 'no-useless-return': 'error', + 'no-whitespace-before-property': 'error', + 'no-with': 'error', + 'object-curly-spacing': [ 'error', 'always' ], + 'one-var-declaration-per-line': [ 'error', 'initializations' ], + 'operator-linebreak': [ 'error', 'after' ], + 'padded-blocks': [ 'error', 'never' ], + 'quote-props': [ 'error', 'as-needed' ], + quotes: [ 'error', 'single', { avoidEscape: true } ], + semi: 'error', + 'semi-spacing': 'error', + 'space-before-blocks': [ 'error', 'always' ], + 'space-before-function-paren': [ 'error', { + anonymous: 'never', + named: 'never', + asyncArrow: 'always', + } ], + 'space-in-parens': [ 'error', 'always' ], + 'space-infix-ops': 'error', + 'space-unary-ops': [ 'error', { + overrides: { + '!': true, + }, + } ], + 'valid-typeof': 'error', + 'vars-on-top': 'error', + 'wrap-iife': 'error', + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/esnext.js b/gb-src/packages/eslint-plugin/configs/esnext.js new file mode 100644 index 0000000000000..8ea39cb864966 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/esnext.js @@ -0,0 +1,37 @@ +module.exports = { + env: { + es6: true, + }, + extends: [ + require.resolve( './es5.js' ), + ], + parserOptions: { + sourceType: 'module', + }, + rules: { + // Disable ES5-specific (extended from ES5) + 'vars-on-top': 'off', + + // Enable ESNext-specific + 'arrow-parens': [ 'error', 'always' ], + 'arrow-spacing': 'error', + 'computed-property-spacing': [ 'error', 'always' ], + 'constructor-super': 'error', + 'no-const-assign': 'error', + 'no-dupe-class-members': 'error', + 'no-duplicate-imports': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-constructor': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + 'prefer-const': 'error', + quotes: [ 'error', 'single', { allowTemplateLiterals: true, avoidEscape: true } ], + 'space-unary-ops': [ 'error', { + overrides: { + '!': true, + yield: true, + }, + } ], + 'template-curly-spacing': [ 'error', 'always' ], + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/index.js b/gb-src/packages/eslint-plugin/configs/index.js new file mode 100644 index 0000000000000..035c09a8fa767 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/index.js @@ -0,0 +1 @@ +module.exports = require( 'requireindex' )( __dirname ); diff --git a/gb-src/packages/eslint-plugin/configs/jsdoc.js b/gb-src/packages/eslint-plugin/configs/jsdoc.js new file mode 100644 index 0000000000000..614816cb734a2 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/jsdoc.js @@ -0,0 +1,60 @@ +const globals = require( 'globals' ); + +/** + * Helpful utilities that are globally defined and known to the TypeScript compiler. + * + * @see http://www.typescriptlang.org/docs/handbook/utility-types.html + */ +const typescriptUtilityTypes = [ + 'ArrayLike', + 'Exclude', + 'Extract', + 'InstanceType', + 'Iterable', + 'IterableIterator', + 'NonNullable', + 'Omit', + 'Partial', + 'Pick', + 'PromiseLike', + 'Readonly', + 'ReadonlyArray', + 'ReadonlyMap', + 'ReadonlySet', + 'Record', + 'Required', + 'ReturnType', + 'ThisType', +]; + +module.exports = { + extends: [ + 'plugin:jsdoc/recommended', + ], + settings: { + jsdoc: { + preferredTypes: { + object: 'Object', + }, + tagNamePreference: { + returns: 'return', + yields: 'yield', + }, + }, + }, + rules: { + 'jsdoc/no-undefined-types': [ 'warn', { + definedTypes: [ + // Required to reference browser types because we don't have the `browser` environment enabled for the project. + // Here we filter out all browser globals that don't begin with an uppercase letter because those + // generally refer to window-level event listeners and are not a valid type to reference (e.g. `onclick`). + ...Object.keys( globals.browser ).filter( ( k ) => /^[A-Z]/.test( k ) ), + ...typescriptUtilityTypes, + 'void', + ], + } ], + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/jshint.js b/gb-src/packages/eslint-plugin/configs/jshint.js new file mode 100644 index 0000000000000..d153eeb1a536a --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/jshint.js @@ -0,0 +1,17 @@ +module.exports = { + rules: { + curly: 'error', + eqeqeq: 'error', + 'no-caller': 'error', + 'no-cond-assign': [ 'error', 'except-parens' ], + 'no-eq-null': 'error', + 'no-irregular-whitespace': 'error', + 'no-trailing-spaces': 'error', + 'no-undef': 'error', + 'no-unused-expressions': 'error', + 'no-unused-vars': 'error', + 'one-var': [ 'error', 'always' ], + quotes: [ 'error', 'single' ], + 'wrap-iife': [ 'error', 'any' ], + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/jsx-a11y.js b/gb-src/packages/eslint-plugin/configs/jsx-a11y.js new file mode 100644 index 0000000000000..38dd0ed2a3bf0 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/jsx-a11y.js @@ -0,0 +1,17 @@ +module.exports = { + extends: [ + 'plugin:jsx-a11y/recommended', + ], + plugins: [ + 'jsx-a11y', + ], + rules: { + 'jsx-a11y/label-has-for': [ 'error', { + required: 'id', + } ], + 'jsx-a11y/media-has-caption': 'off', + 'jsx-a11y/no-noninteractive-tabindex': 'off', + 'jsx-a11y/role-has-required-aria-props': 'off', + 'jsx-quotes': 'error', + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/react.js b/gb-src/packages/eslint-plugin/configs/react.js new file mode 100644 index 0000000000000..20d0ccfc797b8 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/react.js @@ -0,0 +1,33 @@ +module.exports = { + extends: [ + 'plugin:react/recommended', + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + plugins: [ + 'react', + 'react-hooks', + ], + rules: { + '@wordpress/no-unused-vars-before-return': [ 'error', { + excludePattern: '^use', + } ], + 'react/display-name': 'off', + 'react/jsx-curly-spacing': [ 'error', { + when: 'always', + children: true, + } ], + 'react/jsx-equals-spacing': 'error', + 'react/jsx-indent': [ 'error', 'tab' ], + 'react/jsx-indent-props': [ 'error', 'tab' ], + 'react/jsx-key': 'error', + 'react/jsx-tag-spacing': 'error', + 'react/no-children-prop': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'error', + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/recommended.js b/gb-src/packages/eslint-plugin/configs/recommended.js new file mode 100644 index 0000000000000..71d8625cd961b --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/recommended.js @@ -0,0 +1,39 @@ +module.exports = { + parser: 'babel-eslint', + extends: [ + require.resolve( './jsx-a11y.js' ), + require.resolve( './custom.js' ), + require.resolve( './react.js' ), + require.resolve( './esnext.js' ), + ], + env: { + node: true, + }, + globals: { + window: true, + document: true, + wp: 'readonly', + }, + overrides: [ + { + // Unit test files and their helpers only. + files: [ + '**/@(test|__tests__)/**/*.js', + '**/?(*.)test.js', + ], + extends: [ + require.resolve( './test-unit.js' ), + ], + }, + { + // End-to-end test files and their helpers only. + files: [ + '**/specs/**/*.js', + '**/?(*.)spec.js', + ], + extends: [ + require.resolve( './test-e2e.js' ), + ], + }, + ], +}; diff --git a/gb-src/packages/eslint-plugin/configs/test-e2e.js b/gb-src/packages/eslint-plugin/configs/test-e2e.js new file mode 100644 index 0000000000000..63a52f0f0d010 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/test-e2e.js @@ -0,0 +1,13 @@ +module.exports = { + extends: [ + 'plugin:jest/recommended', + ], + env: { + browser: true, + }, + globals: { + browser: 'readonly', + page: 'readonly', + wp: 'readonly', + }, +}; diff --git a/gb-src/packages/eslint-plugin/configs/test-unit.js b/gb-src/packages/eslint-plugin/configs/test-unit.js new file mode 100644 index 0000000000000..048ced2fe38d1 --- /dev/null +++ b/gb-src/packages/eslint-plugin/configs/test-unit.js @@ -0,0 +1,5 @@ +module.exports = { + extends: [ + 'plugin:jest/recommended', + ], +}; diff --git a/gb-src/packages/eslint-plugin/docs/rules/dependency-group.md b/gb-src/packages/eslint-plugin/docs/rules/dependency-group.md new file mode 100644 index 0000000000000..02c1c4ccb95fe --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/dependency-group.md @@ -0,0 +1,36 @@ +# Enforce dependencies docblocks formatting (dependency-group) + +Ensures that all top-level package imports adhere to the dependencies grouping conventions as outlined in the [Coding Guidelines](https://github.com/WordPress/gutenberg/blob/master/docs/contributors/coding-guidelines.md#imports). + +Specifically, this ensures that: + +- An import is preceded by "External dependencies", "WordPress dependencies", or "Internal dependencies" as appropriate by the import source. + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +import { get } from 'lodash'; +import { Component } from '@wordpress/element'; +import edit from './edit'; +``` + +Examples of **correct** code for this rule: + +```js +/* + * External dependencies + */ +import { get } from 'lodash'; + +/* + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/* + * Internal dependencies + */ +import edit from './edit'; +``` diff --git a/gb-src/packages/eslint-plugin/docs/rules/gutenberg-phase.md b/gb-src/packages/eslint-plugin/docs/rules/gutenberg-phase.md new file mode 100644 index 0000000000000..abe70575c9cfb --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/gutenberg-phase.md @@ -0,0 +1,62 @@ +# The `GUTENBERG_PHASE` global (gutenberg-phase) + +To enable the use of feature flags in Gutenberg, the GUTENBERG_PHASE global constant was introduced. This constant is replaced with a number value at build time using webpack's define plugin. + +There are a few rules around using this constant: + +- Only access `GUTENBERG_PHASE` via `process.env`, e.g. `process.env.GUTENBERG_PHASE`. This is required since webpack's define plugin only replaces exact matches of `process.env.GUTENBERG_PHASE` in the codebase. +- The `GUTENBERG_PHASE` variable should only be used in a strict equality comparison with a number, e.g. `process.env.GUTENBERG_PHASE === 2` or `process.env.GUTENBERG_PHASE !== 2`. The value of the injected variable should always be a number, so this ensures the correct evaluation of the expression. Furthermore, when `process.env.GUTENBERG_PHASE` is undefined this comparison still returns either true (for `!==`) or false (for `===`), whereas both the `<` and `>` operators will always return false. +- `GUTENBERG_PHASE` should only be used within the condition of an if statement, e.g. `if ( process.env.GUTENBERG_PHASE === 2 ) { // implement feature here }` or ternary `process.env.GUTENBERG_PHASE === 2 ? something : somethingElse`. This rule ensures code that is disabled through a feature flag is removed by dead code elimination. + + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +if ( GUTENBERG_PHASE === 2 ) { + // implement feature here. +} +``` + +```js +if ( window[ 'GUTENBERG_PHASE' ] === 2 ) { + // implement feature here. +} +``` + +```js +if ( process.env.GUTENBERG_PHASE === '2' ) { + // implement feature here. +} +``` + +```js +if ( process.env.GUTENBERG_PHASE > 2 ) { + // implement feature here. +} +``` + +```js +if ( true || process.env.GUTENBERG_PHASE > 2 ) { + // implement feature here. +} +``` + +```js +const isMyFeatureActive = process.env.GUTENBERG_PHASE === 2; +``` + +Examples of **correct** code for this rule: + +```js +if ( process.env.GUTENBERG_PHASE === 2 ) { + // implement feature here. +} +``` + +```js +if ( process.env.GUTENBERG_PHASE !== 2 ) { + return; +} +``` diff --git a/gb-src/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md b/gb-src/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md new file mode 100644 index 0000000000000..4dea158a3be12 --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md @@ -0,0 +1,45 @@ +# Disallow the usage of BaseControl component with a label prop set but omitting the id property (no-base-control-with-label-without-id) + +Base control component ideally should be used together with components providing user input. The label the BaseControl component receives, should be associated with some component providing user via an id attribute. +If a label is provided but the id is omitted it means that the developer missed the id prop or that BaseControl is not a good fit for the use case and a div together with a span can provide the same functionality. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx + + + +``` + + +```jsx + +``` + +Examples of **correct** code for this rule: + + +```jsx + +``` + +```jsx + + + +``` + +```jsx + + + +``` diff --git a/gb-src/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md b/gb-src/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md new file mode 100644 index 0000000000000..1dd2e3e3ec44b --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md @@ -0,0 +1,18 @@ +# Avoid unguarded getRangeAt (no-unguarded-get-range-at) + +Some browsers (e.g. Safari) will throw an error when `getRangeAt` is called and there are no ranges in the selection. + +## Rule details + +Example of **incorrect** code for this rule: + +```js +window.getSelection().getRangeAt( 0 ); +``` + +Example of **correct** code for this rule: + +```js +const selection = window.getSelection(); +const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; +``` diff --git a/gb-src/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md b/gb-src/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md new file mode 100644 index 0000000000000..b8d4ca33efdd8 --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md @@ -0,0 +1,37 @@ +# Avoid assigning variable values if unused before a return (no-unused-vars-before-return) + +To avoid wastefully computing the result of a function call when assigning a variable value, the variable should be declared as late as possible. In practice, if a function includes an early return path, any variable declared prior to the `return` must be referenced at least once. Otherwise, in the condition which satisfies that return path, the declared variable is effectively considered dead code. This can have a performance impact when the logic performed in assigning the value is non-trivial. + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +function example( number ) { + const foo = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + 1; + } + + return number + foo; +} +``` + +Examples of **correct** code for this rule: + +```js +function example( number ) { + if ( number > 10 ) { + return number + 1; + } + + const foo = doSomeCostlyOperation(); + return number + foo; +} +``` + +## Options + +This rule accepts a single options argument: + +- Set the `excludePattern` option to a regular expression string to exempt specific function calls by name. diff --git a/gb-src/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md b/gb-src/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md new file mode 100644 index 0000000000000..8dc1dabf69baf --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md @@ -0,0 +1,76 @@ +# Disallow unsafe `setTimeout` in component (react-no-unsafe-timeout) + +`setTimeout` in a component must be cancelled when the component is unmounted. This rule disallows references to the `setTimeout` global which occur in a component, and which are not assigned to a variable. A variable assignment is considered an acceptable use on the assumption that the timeout ID will be later referenced for cancellation via `clearTimeout`. + +Consider using the [`withSafeTimeout` higher-order component](https://github.com/WordPress/gutenberg/tree/master/packages/compose/src/with-safe-timeout) from the [`@wordpress/compose` module](https://www.npmjs.com/package/@wordpress/compose). + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +function MyComponent() { + setTimeout( fn ); +} + +class MyComponent extends Component { + componentDidMount() { + setTimeout( fn ); + } +} + +class MyComponent extends wp.element.Component { + componentDidMount() { + setTimeout( fn ); + } +} +``` + +Examples of **correct** code for this rule: + +```js +function getNotComponent() { + setTimeout( fn ); +} + +function MyComponent( props ) { + const { setTimeout } = props; + setTimeout( fn ); +} + +function MyComponent( props ) { + props.setTimeout( fn ); +} + +class MyNotComponent { + doAction() { + setTimeout( fn ); + } +} + +class MyComponent extends wp.element.Component { + componentDidMount() { + const { setTimeout } = this.props; + setTimeout( fn ); + } +} + +class MyComponent extends Component { + componentDidMount() { + const { setTimeout } = this.props; + setTimeout( fn ); + } +} + +class MyComponent extends Component { + componentDidMount() { + this.props.setTimeout( fn ); + } +} + +class MyComponent extends Component { + componentDidMount() { + this.timeoutId = setTimeout( fn ); + } +} +``` diff --git a/gb-src/packages/eslint-plugin/docs/rules/valid-sprintf.md b/gb-src/packages/eslint-plugin/docs/rules/valid-sprintf.md new file mode 100644 index 0000000000000..0ff0ccd67431f --- /dev/null +++ b/gb-src/packages/eslint-plugin/docs/rules/valid-sprintf.md @@ -0,0 +1,28 @@ +# Enforce valid sprintf usage (valid-sprintf) + +[`sprintf`](https://github.com/WordPress/gutenberg/blob/master/packages/i18n/README.md#api) must be called with a valid format string with at least one placeholder, and with a valid set of placeholder substitute values. + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +sprintf(); +sprintf( '%s' ); +sprintf( 1, 'substitute' ); +sprintf( [], 'substitute' ); +sprintf( '%%', 'substitute' ); +sprintf( __( '%%' ), 'substitute' ); +sprintf( _n( '%s', '' ), 'substitute' ); +sprintf( _n( '%s', '%s %s' ), 'substitute' ); +``` + +Examples of **correct** code for this rule: + +```js +sprintf( '%s', 'substitute' ); +sprintf( __( '%s' ), 'substitute' ); +sprintf( _x( '%s' ), 'substitute' ); +sprintf( _n( '%s', '%s' ), 'substitute' ); +sprintf( _nx( '%s', '%s' ), 'substitute' ); +``` diff --git a/gb-src/packages/eslint-plugin/index.js b/gb-src/packages/eslint-plugin/index.js new file mode 100644 index 0000000000000..fcba80b48203a --- /dev/null +++ b/gb-src/packages/eslint-plugin/index.js @@ -0,0 +1,4 @@ +module.exports = { + configs: require( './configs' ), + rules: require( './rules' ), +}; diff --git a/gb-src/packages/eslint-plugin/package.json b/gb-src/packages/eslint-plugin/package.json new file mode 100644 index 0000000000000..2846c228cd64e --- /dev/null +++ b/gb-src/packages/eslint-plugin/package.json @@ -0,0 +1,42 @@ +{ + "name": "@wordpress/eslint-plugin", + "version": "3.1.0", + "description": "ESLint plugin for WordPress development.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "eslint" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/eslint-plugin/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/eslint-plugin" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "files": [ + "configs", + "rules", + "index.js" + ], + "main": "index.js", + "dependencies": { + "babel-eslint": "^10.0.2", + "eslint-plugin-jest": "^22.15.1", + "eslint-plugin-jsdoc": "^15.8.0", + "eslint-plugin-jsx-a11y": "^6.2.3", + "eslint-plugin-react": "^7.14.3", + "eslint-plugin-react-hooks": "^1.6.1", + "globals": "^12.0.0", + "requireindex": "^1.2.0" + }, + "peerDependencies": { + "eslint": "6.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/dependency-group.js b/gb-src/packages/eslint-plugin/rules/__tests__/dependency-group.js new file mode 100644 index 0000000000000..2089b9ab3ec02 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/dependency-group.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../dependency-group'; + +const ruleTester = new RuleTester( { + parserOptions: { + sourceType: 'module', + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'dependency-group', rule, { + valid: [ + { + code: ` +/** + * External dependencies + */ +import { get } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import edit from './edit';`, + }, + ], + invalid: [ + { + code: ` +import { get } from 'lodash'; +import classnames from 'classnames'; +/* + * wordpress dependencies. + */ +import { Component } from '@wordpress/element'; +import edit from './edit';`, + errors: [ + { message: 'Expected preceding "External dependencies" comment block' }, + { message: 'Expected preceding "WordPress dependencies" comment block' }, + { message: 'Expected preceding "Internal dependencies" comment block' }, + ], + output: ` +/** + * External dependencies + */ +import { get } from 'lodash'; +import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +/** + * Internal dependencies + */ +import edit from './edit';`, + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/gutenberg-phase.js b/gb-src/packages/eslint-plugin/rules/__tests__/gutenberg-phase.js new file mode 100644 index 0000000000000..579e127855090 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/gutenberg-phase.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../gutenberg-phase'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + }, +} ); + +const ACCESS_ERROR = 'The `GUTENBERG_PHASE` constant should be accessed using `process.env.GUTENBERG_PHASE`.'; +const EQUALITY_ERROR = 'The `GUTENBERG_PHASE` constant should only be used in a strict equality comparison with a primitive number.'; +const IF_ERROR = 'The `GUTENBERG_PHASE` constant should only be used as part of the condition in an if statement or ternary expression.'; + +ruleTester.run( 'gutenberg-phase', rule, { + valid: [ + { code: `if ( process.env.GUTENBERG_PHASE === 2 ) {}` }, + { code: `if ( process.env.GUTENBERG_PHASE !== 2 ) {}` }, + { code: `if ( 2 === process.env.GUTENBERG_PHASE ) {}` }, + { code: `if ( 2 !== process.env.GUTENBERG_PHASE ) {}` }, + { code: `const test = process.env.GUTENBERG_PHASE === 2 ? foo : bar` }, + { code: `const test = process.env.GUTENBERG_PHASE !== 2 ? foo : bar` }, + ], + invalid: [ + { + code: `if ( GUTENBERG_PHASE === 1 ) {}`, + errors: [ { message: ACCESS_ERROR } ], + }, + { + code: `if ( window[ 'GUTENBERG_PHASE' ] === 1 ) {}`, + errors: [ { message: ACCESS_ERROR } ], + }, + { + code: `if ( process.env.GUTENBERG_PHASE > 1 ) {}`, + errors: [ { message: EQUALITY_ERROR } ], + }, + { + code: `if ( process.env.GUTENBERG_PHASE === '2' ) {}`, + errors: [ { message: EQUALITY_ERROR } ], + }, + { + code: `if ( true ) { process.env.GUTENBERG_PHASE === 2 }`, + errors: [ { message: IF_ERROR } ], + }, + { + code: `if ( true || process.env.GUTENBERG_PHASE === 2 ) {}`, + errors: [ { message: IF_ERROR } ], + }, + { + code: `const isFeatureActive = process.env.GUTENBERG_PHASE === 2;`, + errors: [ { message: IF_ERROR } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/no-base-control-with-label-without-id.js b/gb-src/packages/eslint-plugin/rules/__tests__/no-base-control-with-label-without-id.js new file mode 100644 index 0000000000000..a03a3a2296f7b --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/no-base-control-with-label-without-id.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../no-base-control-with-label-without-id'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, +} ); + +ruleTester.run( 'no-base-control-with-label-without-id', rule, { + valid: [ + { + code: ` + `, + }, + { + code: ``, + }, + { + code: ` + + + `, + }, + { + code: ` + + + `, + }, + { + code: ` + + + `, + }, + ], + invalid: [ + { + code: ` + + + `, + errors: [ { message: 'When using BaseControl component if a label property is passed an id property should also be passed.' } ], + }, + { + code: ` + `, + errors: [ { message: 'When using BaseControl component if a label property is passed an id property should also be passed.' } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/no-unguarded-get-range-at.js b/gb-src/packages/eslint-plugin/rules/__tests__/no-unguarded-get-range-at.js new file mode 100644 index 0000000000000..b3e0a7592f8f0 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/no-unguarded-get-range-at.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../no-unguarded-get-range-at'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'no-unguarded-get-range-at', rule, { + valid: [ + { + code: `const selection = window.getSelection(); const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;`, + }, + ], + invalid: [ + { + code: `window.getSelection().getRangeAt( 0 );`, + errors: [ { message: 'Avoid unguarded getRangeAt' } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/no-unused-vars-before-return.js b/gb-src/packages/eslint-plugin/rules/__tests__/no-unused-vars-before-return.js new file mode 100644 index 0000000000000..c8fa8c4a216ae --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/no-unused-vars-before-return.js @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../no-unused-vars-before-return'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'no-unused-vars-before-return', rule, { + valid: [ + { + code: ` +function example( number ) { + if ( number > 10 ) { + return number + 1; + } + + const foo = doSomeCostlyOperation(); + return number + foo; +}`, + }, + { + code: ` +function example() { + const { foo, bar } = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + bar + 1; + } + + return number + foo; +}`, + }, + { + code: ` +function example() { + const foo = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + 1; + } + + return number + foo; +}`, + options: [ { excludePattern: '^do' } ], + }, + ], + invalid: [ + { + code: ` +function example( number ) { + const foo = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + 1; + } + + return number + foo; +}`, + errors: [ { message: 'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.' } ], + }, + { + code: ` +function example() { + const { foo } = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + 1; + } + + return number + foo; +}`, + errors: [ { message: 'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.' } ], + }, + { + code: ` +function example() { + const foo = doSomeCostlyOperation(); + if ( number > 10 ) { + return number + 1; + } + + return number + foo; +}`, + options: [ { excludePattern: '^run' } ], + errors: [ { message: 'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.' } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/react-no-unsafe-timeout.js b/gb-src/packages/eslint-plugin/rules/__tests__/react-no-unsafe-timeout.js new file mode 100644 index 0000000000000..85046a73b5610 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/react-no-unsafe-timeout.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../react-no-unsafe-timeout'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'react-no-unsafe-timeout', rule, { + valid: [ + { + code: `function getNotComponent() { setTimeout(); }`, + }, + { + code: `function MyComponent( props ) { const { setTimeout } = props; ( () => { setTimeout(); } )(); }`, + }, + { + code: `function MyComponent( props ) { props.setTimeout(); }`, + }, + { + code: `class MyNotComponent { doAction() { setTimeout(); } }`, + }, + { + code: `class MyComponent extends wp.element.Component { componentDidMount() { const { setTimeout } = this.props; setTimeout(); } }`, + }, + { + code: `class MyComponent extends Component { componentDidMount() { const { setTimeout } = this.props; setTimeout(); } }`, + }, + { + code: `class MyComponent extends Component { componentDidMount() { this.props.setTimeout(); } }`, + }, + { + code: `class MyComponent extends Component { componentDidMount() { this.timeoutId = setTimeout(); } }`, + }, + { + code: ` +function MyComponent() { + useEffect( () => { + const timeoutHandle = setTimeout( () => {} ); + + return () => clearTimeout( timeoutHandle ); + }, [] ); + + return null; +}`, + }, + ], + invalid: [ + { + code: `function MyComponent() { setTimeout(); }`, + errors: [ { message: 'setTimeout in a component must be cancelled on unmount' } ], + }, + { + code: `class MyComponent extends Component { componentDidMount() { setTimeout(); } }`, + errors: [ { message: 'setTimeout in a component must be cancelled on unmount' } ], + }, + { + code: `class MyComponent extends wp.element.Component { componentDidMount() { setTimeout(); } }`, + errors: [ { message: 'setTimeout in a component must be cancelled on unmount' } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/__tests__/valid-sprintf.js b/gb-src/packages/eslint-plugin/rules/__tests__/valid-sprintf.js new file mode 100644 index 0000000000000..06664969d8148 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/__tests__/valid-sprintf.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../valid-sprintf'; + +const ruleTester = new RuleTester( { + parserOptions: { + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'valid-sprintf', rule, { + valid: [ + { + code: `sprintf( '%s', 'substitute' )`, + }, + { + code: `sprintf( __( '%s' ), 'substitute' )`, + }, + { + code: `sprintf( _x( '%s' ), 'substitute' )`, + }, + { + code: `sprintf( _n( '%s', '%s' ), 'substitute' )`, + }, + { + code: `sprintf( _nx( '%s', '%s' ), 'substitute' )`, + }, + { + code: `var getValue = () => ''; sprintf( getValue(), 'substitute' )`, + }, + { + code: `var value = ''; sprintf( value, 'substitute' )`, + }, + ], + invalid: [ + { + code: `sprintf()`, + errors: [ { message: 'sprintf must be called with a format string' } ], + }, + { + code: `sprintf( '%s' )`, + errors: [ { message: 'sprintf must be called with placeholder value argument(s)' } ], + }, + { + code: `sprintf( 1, 'substitute' )`, + errors: [ { message: 'sprintf must be called with a valid format string' } ], + }, + { + code: `sprintf( [], 'substitute' )`, + errors: [ { message: 'sprintf must be called with a valid format string' } ], + }, + { + code: `sprintf( '%%', 'substitute' )`, + errors: [ { message: 'sprintf format string must contain at least one placeholder' } ], + }, + { + code: `sprintf( __( '%%' ), 'substitute' )`, + errors: [ { message: 'sprintf format string must contain at least one placeholder' } ], + }, + { + code: `sprintf( _n( '%s', '' ), 'substitute' )`, + errors: [ { message: 'sprintf format string options must have the same number of placeholders' } ], + }, + { + code: `sprintf( _n( '%s', '%s %s' ), 'substitute' )`, + errors: [ { message: 'sprintf format string options must have the same number of placeholders' } ], + }, + ], +} ); diff --git a/gb-src/packages/eslint-plugin/rules/dependency-group.js b/gb-src/packages/eslint-plugin/rules/dependency-group.js new file mode 100644 index 0000000000000..995250e53c187 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/dependency-group.js @@ -0,0 +1,215 @@ +module.exports = { + meta: { + type: 'layout', + docs: { + description: 'Enforce dependencies docblocks formatting', + url: 'https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/dependency-group.md', + }, + schema: [], + fixable: true, + }, + create( context ) { + const comments = context.getSourceCode().getAllComments(); + + /** + * Locality classification of an import, one of "External", + * "WordPress", "Internal". + * + * @typedef {string} WPPackageLocality + */ + + /** + * Object describing a dependency block correction to be made. + * + * @property {?espree.Node} comment Comment node on which to replace + * value, if one can be salvaged. + * @property {string} value Expected comment node value. + * + * @typedef {Object} WPDependencyBlockCorrection + */ + + /** + * Given a desired locality, generates the expected comment node value + * property. + * + * @param {WPPackageLocality} locality Desired package locality. + * + * @return {string} Expected comment node value. + */ + function getCommentValue( locality ) { + return `*\n * ${ locality } dependencies\n `; + } + + /** + * Given an import source string, returns the locality classification + * of the import sort. + * + * @param {string} source Import source string. + * + * @return {WPPackageLocality} Package locality. + */ + function getPackageLocality( source ) { + if ( source.startsWith( '.' ) ) { + return 'Internal'; + } else if ( source.startsWith( '@wordpress/' ) ) { + return 'WordPress'; + } + + return 'External'; + } + + /** + * Returns true if the given comment node satisfies a desired locality, + * or false otherwise. + * + * @param {espree.Node} node Comment node to check. + * @param {WPPackageLocality} locality Desired package locality. + * + * @return {boolean} Whether comment node satisfies locality. + */ + function isLocalityDependencyBlock( node, locality ) { + const { type, value } = node; + if ( type !== 'Block' ) { + return false; + } + + // Tolerances: + // - Normalize `/**` and `/*` + // - Case insensitive "Dependencies" vs. "dependencies" + // - Ending period + // - "Node" dependencies as an alias for External + + if ( locality === 'External' ) { + locality = '(External|Node)'; + } + + const pattern = new RegExp( `^\\*?\\n \\* ${ locality } dependencies\\.?\\n $`, 'i' ); + return pattern.test( value ); + } + + /** + * Returns true if the given node occurs prior in code to a reference, + * or false otherwise. + * + * @param {espree.Node} node Node to test being before reference. + * @param {espree.Node} reference Node against which to compare. + * + * @return {boolean} Whether node occurs before reference. + */ + function isBefore( node, reference ) { + return node.start < reference.start; + } + + /** + * Tests source comments to determine whether a comment exists which + * satisfies the desired locality. If a match is found and requires no + * updates, the function returns undefined. Otherwise, it will return + * a WPDependencyBlockCorrection object describing a correction. + * + * @param {espree.Node} node Node to test. + * @param {WPPackageLocality} locality Desired package locality. + * + * @return {?WPDependencyBlockCorrection} Correction, if applicable. + */ + function getDependencyBlockCorrection( node, locality ) { + const value = getCommentValue( locality ); + + let comment; + for ( let i = 0; i < comments.length; i++ ) { + comment = comments[ i ]; + + if ( ! isBefore( comment, node ) ) { + // Exhausted options. + break; + } + + if ( ! isLocalityDependencyBlock( comment, locality ) ) { + // Not usable (either not an block comment, or not one + // matching a tolerable pattern). + continue; + } + + if ( comment.value === value ) { + // No change needed. (OK) + return; + } + + // Found a comment needing correction. + return { comment, value }; + } + + return { value }; + } + + return { + Program( node ) { + /** + * The set of package localities which have been reported for + * the current program. Each locality is reported at most one + * time, since otherwise the fixer would insert a comment + * block for each individual import statement. + * + * @type {Set} + */ + const verified = new Set(); + + // Since we only care to enforce imports which occur at the + // top-level scope, match on Program and test its children, + // rather than matching the import nodes directly. + node.body.forEach( ( child ) => { + let source; + switch ( child.type ) { + case 'ImportDeclaration': + source = child.source.value; + break; + + case 'CallExpression': + const { callee, arguments: args } = child; + if ( + callee.name === 'require' && + args.length === 1 && + args[ 0 ].type === 'Literal' && + typeof args[ 0 ].value === 'string' + ) { + source = args[ 0 ].value; + } + break; + } + + if ( ! source ) { + return; + } + + const locality = getPackageLocality( source ); + if ( verified.has( locality ) ) { + return; + } + + // Avoid verifying any other imports for the locality, + // regardless whether a correction must be made. + verified.add( locality ); + + // Determine whether a correction must be made. + const correction = getDependencyBlockCorrection( child, locality ); + if ( ! correction ) { + return; + } + + context.report( { + node: child, + message: `Expected preceding "${ locality } dependencies" comment block`, + fix( fixer ) { + const { comment, value } = correction; + const text = `/*${ value }*/`; + if ( comment ) { + return fixer.replaceText( comment, text ); + } + + return fixer.insertTextBefore( child, text + '\n' ); + }, + } ); + } ); + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/gutenberg-phase.js b/gb-src/packages/eslint-plugin/rules/gutenberg-phase.js new file mode 100644 index 0000000000000..168ce1ff782d0 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/gutenberg-phase.js @@ -0,0 +1,165 @@ +/** + * Traverse up through the chain of parent AST nodes returning the first parent + * the predicate returns a truthy value for. + * + * @param {Object} sourceNode The AST node to search from. + * @param {Function} predicate A predicate invoked for each parent. + * + * @return {?Object } The first encountered parent node where the predicate + * returns a truthy value. + */ +function findParent( sourceNode, predicate ) { + if ( ! sourceNode.parent ) { + return; + } + + if ( predicate( sourceNode.parent ) ) { + return sourceNode.parent; + } + + return findParent( sourceNode.parent, predicate ); +} + +/** + * Tests whether the GUTENBERG_PHASE variable is accessed via + * `process.env.GUTENBERG_PHASE`. + * + * @example + * ```js + * // good + * if ( process.env.GUTENBERG_PHASE === 2 ) { + * + * // bad + * if ( GUTENBERG_PHASE === 2 ) { + * ``` + * + * @param {Object} node The GUTENBERG_PHASE identifier node. + * @param {Object} context The eslint context object. + */ +function testIsAccessedViaProcessEnv( node, context ) { + const parent = node.parent; + + if ( + parent && + parent.type === 'MemberExpression' && + context.getSource( parent ) === 'process.env.GUTENBERG_PHASE' + + ) { + return; + } + + context.report( + node, + 'The `GUTENBERG_PHASE` constant should be accessed using `process.env.GUTENBERG_PHASE`.', + ); +} + +/** + * Tests whether the GUTENBERG_PHASE variable is used in a strict binary + * equality expression in a comparison with a number, triggering a + * violation if not. + * + * @example + * ```js + * // good + * if ( process.env.GUTENBERG_PHASE === 2 ) { + * + * // bad + * if ( process.env.GUTENBERG_PHASE >= '2' ) { + * ``` + * + * @param {Object} node The GUTENBERG_PHASE identifier node. + * @param {Object} context The eslint context object. + */ +function testIsUsedInStrictBinaryExpression( node, context ) { + const parent = findParent( node, ( candidate ) => candidate.type === 'BinaryExpression' ); + + if ( parent ) { + const comparisonNode = node.parent.type === 'MemberExpression' ? node.parent : node; + + // Test for process.env.GUTENBERG_PHASE === or === process.env.GUTENBERG_PHASE + const hasCorrectOperator = [ '===', '!==' ].includes( parent.operator ); + const hasCorrectOperands = ( + ( parent.left === comparisonNode && typeof parent.right.value === 'number' ) || + ( parent.right === comparisonNode && typeof parent.left.value === 'number' ) + ); + + if ( hasCorrectOperator && hasCorrectOperands ) { + return; + } + } + + context.report( + node, + 'The `GUTENBERG_PHASE` constant should only be used in a strict equality comparison with a primitive number.', + ); +} + +/** + * Tests whether the GUTENBERG_PHASE variable is used as the condition for an + * if statement, triggering a violation if not. + * + * @example + * ```js + * // good + * if ( process.env.GUTENBERG_PHASE === 2 ) { + * + * // bad + * const isFeatureActive = process.env.GUTENBERG_PHASE === 2; + * ``` + * + * @param {Object} node The GUTENBERG_PHASE identifier node. + * @param {Object} context The eslint context object. + */ +function testIsUsedInIfOrTernary( node, context ) { + const conditionalParent = findParent( + node, + ( candidate ) => [ 'IfStatement', 'ConditionalExpression' ].includes( candidate.type ) + ); + const binaryParent = findParent( node, ( candidate ) => candidate.type === 'BinaryExpression' ); + + if ( conditionalParent && + binaryParent && + conditionalParent.test && + conditionalParent.test.start === binaryParent.start && + conditionalParent.test.end === binaryParent.end + ) { + return; + } + + context.report( + node, + 'The `GUTENBERG_PHASE` constant should only be used as part of the condition in an if statement or ternary expression.', + ); +} + +module.exports = { + meta: { + type: 'problem', + schema: [], + }, + create( context ) { + return { + Identifier( node ) { + // Bypass any identifiers with a node name different to `GUTENBERG_PHASE`. + if ( node.name !== 'GUTENBERG_PHASE' ) { + return; + } + + testIsAccessedViaProcessEnv( node, context ); + testIsUsedInStrictBinaryExpression( node, context ); + testIsUsedInIfOrTernary( node, context ); + }, + Literal( node ) { + // Bypass any identifiers with a node value different to `GUTENBERG_PHASE`. + if ( node.value !== 'GUTENBERG_PHASE' ) { + return; + } + + if ( node.parent && node.parent.type === 'MemberExpression' ) { + testIsAccessedViaProcessEnv( node, context ); + } + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/index.js b/gb-src/packages/eslint-plugin/rules/index.js new file mode 100644 index 0000000000000..035c09a8fa767 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/index.js @@ -0,0 +1 @@ +module.exports = require( 'requireindex' )( __dirname ); diff --git a/gb-src/packages/eslint-plugin/rules/no-base-control-with-label-without-id.js b/gb-src/packages/eslint-plugin/rules/no-base-control-with-label-without-id.js new file mode 100644 index 0000000000000..8d82e00a171f1 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/no-base-control-with-label-without-id.js @@ -0,0 +1,26 @@ +module.exports = { + meta: { + type: 'problem', + schema: [], + }, + create( context ) { + return { + 'JSXOpeningElement[name.name=\'BaseControl\']': ( node ) => { + const containsAttribute = ( attrName ) => { + return node.attributes.some( ( attribute ) => { + return attribute.name && attribute.name.name === attrName; + } ); + }; + if ( + containsAttribute( 'label' ) && + ! containsAttribute( 'id' ) + ) { + context.report( { + node, + message: 'When using BaseControl component if a label property is passed an id property should also be passed.', + } ); + } + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/no-unguarded-get-range-at.js b/gb-src/packages/eslint-plugin/rules/no-unguarded-get-range-at.js new file mode 100644 index 0000000000000..e74bc775de54f --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/no-unguarded-get-range-at.js @@ -0,0 +1,16 @@ +module.exports = { + meta: { + type: 'problem', + schema: [], + }, + create( context ) { + return { + 'CallExpression[callee.object.callee.object.name="window"][callee.object.callee.property.name="getSelection"][callee.property.name="getRangeAt"]'( node ) { + context.report( { + node, + message: 'Avoid unguarded getRangeAt', + } ); + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/no-unused-vars-before-return.js b/gb-src/packages/eslint-plugin/rules/no-unused-vars-before-return.js new file mode 100644 index 0000000000000..fe8f20f4ded76 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/no-unused-vars-before-return.js @@ -0,0 +1,91 @@ +module.exports = { + meta: { + type: 'problem', + schema: [ + { + type: 'object', + properties: { + excludePattern: { + type: 'string', + }, + }, + additionalProperties: false, + }, + ], + }, + create( context ) { + const options = context.options[ 0 ] || {}; + const { excludePattern } = options; + + /** + * Given an Espree VariableDeclarator node, returns true if the node + * can be exempted from consideration as unused, or false otherwise. A + * node can be exempt if it destructures to multiple variables, since + * those other variables may be used prior to the return statement. A + * future enhancement could validate that they are in-fact referenced. + * + * @param {Object} node Node to test. + * + * @return {boolean} Whether declarator is emempt from consideration. + */ + function isExemptObjectDestructureDeclarator( node ) { + return node.id.type === 'ObjectPattern' && node.id.properties.length > 1; + } + + return { + ReturnStatement( node ) { + let functionScope = context.getScope(); + while ( functionScope.type !== 'function' && functionScope.upper ) { + functionScope = functionScope.upper; + } + + if ( ! functionScope ) { + return; + } + + for ( const variable of functionScope.variables ) { + const declaratorCandidate = variable.defs.find( ( def ) => { + return ( + def.node.type === 'VariableDeclarator' && + // Allow declarations which are not initialized. + def.node.init && + // Target function calls as "expensive". + def.node.init.type === 'CallExpression' && + // Allow unused if part of an object destructuring. + ! isExemptObjectDestructureDeclarator( def.node ) && + // Only target assignments preceding `return`. + def.node.end < node.end + ); + } ); + + if ( ! declaratorCandidate ) { + continue; + } + + if ( + excludePattern !== undefined && + new RegExp( excludePattern ).test( declaratorCandidate.node.init.callee.name ) + ) { + return; + } + + // The first entry in `references` is the declaration + // itself, which can be ignored. + const isUsedBeforeReturn = variable.references.slice( 1 ).some( ( reference ) => { + return reference.identifier.end < node.end; + } ); + + if ( isUsedBeforeReturn ) { + continue; + } + + context.report( + declaratorCandidate.node, + 'Variables should not be assigned until just prior its first reference. ' + + 'An early return statement may leave this variable unused.' + ); + } + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/react-no-unsafe-timeout.js b/gb-src/packages/eslint-plugin/rules/react-no-unsafe-timeout.js new file mode 100644 index 0000000000000..f97a4d7384fe3 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/react-no-unsafe-timeout.js @@ -0,0 +1,93 @@ +/** + * Given an Espree Node, returns true if the node is a component. + * + * @param {espree.Node} node Node to check. + * + * @return {boolean} Whether node is a component. + */ +function isComponent( node ) { + // Assume function component by naming convention of UpperCamelCase. + if ( + node.type === 'FunctionDeclaration' && + node.id && + /^[A-Z]/.test( node.id.name ) + ) { + return true; + } + + // Assume class component by extends name `Component`. + if ( node.type === 'ClassDeclaration' && node.superClass ) { + let superClassName; + switch ( node.superClass.type ) { + case 'Identifier': + superClassName = node.superClass.name; + break; + + case 'MemberExpression': + superClassName = node.superClass.property.name; + break; + } + + if ( superClassName === 'Component' ) { + return true; + } + } + + return false; +} + +module.exports = { + meta: { + type: 'problem', + schema: [], + }, + create( context ) { + return { + 'CallExpression[callee.name="setTimeout"]'( node ) { + // If the result of a `setTimeout` call is assigned to a + // variable, assume the timer ID is handled by a cancellation. + const hasAssignment = ( + node.parent.type === 'AssignmentExpression' || + node.parent.type === 'VariableDeclarator' + ); + if ( hasAssignment ) { + return; + } + + let isInComponent = false; + + let parent = node; + while ( ( parent = parent.parent ) ) { + if ( isComponent( parent ) ) { + isInComponent = true; + break; + } + } + + // Only consider `setTimeout` which occur within a component. + if ( ! isInComponent ) { + return; + } + + // Consider whether `setTimeout` is a reference to the global + // by checking references to see if `setTimeout` resolves to a + // variable in scope. + const { references } = context.getScope(); + const hasResolvedReference = references.some( ( reference ) => ( + reference.identifier.name === 'setTimeout' && + !! reference.resolved && + reference.resolved.scope.type !== 'global' + ) ); + + if ( hasResolvedReference ) { + return; + } + + context.report( + node, + 'setTimeout in a component must be cancelled on unmount' + ); + }, + }; + }, +}; diff --git a/gb-src/packages/eslint-plugin/rules/valid-sprintf.js b/gb-src/packages/eslint-plugin/rules/valid-sprintf.js new file mode 100644 index 0000000000000..73101d12b7d67 --- /dev/null +++ b/gb-src/packages/eslint-plugin/rules/valid-sprintf.js @@ -0,0 +1,150 @@ +/** + * Regular expression matching the presence of a printf format string + * placeholder. This naive pattern which does not validate the format. + * + * @type {RegExp} + */ +const REGEXP_PLACEHOLDER = /%[^%]/g; + +/** + * Given a function name and array of argument Node values, returns all + * possible string results from the corresponding translate function, or + * undefined if the function is not a translate function. + * + * @param {string} functionName Function name. + * @param {espree.Node[]} args Espree argument Node objects. + * + * @return {?Array} All possible translate function string results. + */ +function getTranslateStrings( functionName, args ) { + switch ( functionName ) { + case '__': + case '_x': + args = args.slice( 0, 1 ); + break; + + case '_n': + case '_nx': + args = args.slice( 0, 2 ); + break; + + default: + return; + } + + return args + .filter( ( arg ) => arg.type === 'Literal' ) + .map( ( arg ) => arg.value ); +} + +module.exports = { + meta: { + type: 'problem', + schema: [], + }, + create( context ) { + return { + CallExpression( node ) { + const { callee, arguments: args } = node; + if ( callee.name !== 'sprintf' ) { + return; + } + + if ( ! args.length ) { + context.report( + node, + 'sprintf must be called with a format string' + ); + return; + } + + if ( args.length < 2 ) { + context.report( + node, + 'sprintf must be called with placeholder value argument(s)' + ); + return; + } + + let candidates; + switch ( args[ 0 ].type ) { + case 'Literal': + candidates = [ args[ 0 ].value ].filter( ( arg ) => { + // Since a Literal may be a number, verify the + // value is a string. + return typeof arg === 'string'; + } ); + break; + + case 'CallExpression': + // All possible options (arguments) from a translate + // function must be valid. + candidates = getTranslateStrings( + args[ 0 ].callee.name, + args[ 0 ].arguments + ); + + // An unknown function call may produce a valid string + // value. Ideally its result is verified, but this is + // not straight-forward to implement. Thus, bail. + if ( candidates === undefined ) { + return; + } + + break; + + case 'Identifier': + // Identifiers may refer to a valid string variable. + // Ideally its reference value is verified, but this is + // not straight-forward to implement. Thus, bail. + return; + + default: + candidates = []; + } + + if ( ! candidates.length ) { + context.report( + node, + 'sprintf must be called with a valid format string' + ); + return; + } + + let numPlaceholders; + for ( let i = 0; i < candidates.length; i++ ) { + const match = candidates[ i ].match( REGEXP_PLACEHOLDER ); + + // Prioritize placeholder number consistency over matching + // placeholder, since it's a more common error to omit a + // placeholder from the singular form of pluralization. + if ( + numPlaceholders !== undefined && + ( ! match || numPlaceholders !== match.length ) + ) { + context.report( + node, + 'sprintf format string options must have the same number of placeholders' + ); + return; + } + + if ( ! match ) { + context.report( + node, + 'sprintf format string must contain at least one placeholder' + ); + return; + } + + if ( numPlaceholders === undefined ) { + // Track the number of placeholders discovered in the + // string to verify that all other candidate options + // have the same number. + numPlaceholders = match.length; + } + } + }, + }; + }, +}; diff --git a/gb-src/packages/format-library/.npmrc b/gb-src/packages/format-library/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/format-library/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/format-library/CHANGELOG.md b/gb-src/packages/format-library/CHANGELOG.md new file mode 100644 index 0000000000000..2695afd664062 --- /dev/null +++ b/gb-src/packages/format-library/CHANGELOG.md @@ -0,0 +1,54 @@ +## 1.8.0 (2019-08-29) + +### Internal + +- The inline link component has been refactored to focus changes using the `Popover` component's `onFocusOutside` prop. + +## 1.2.10 (2019-01-03) + +## 1.2.9 (2018-12-18) + +## 1.2.8 (2018-12-12) + +## 1.2.7 (2018-11-30) + +## 1.2.6 (2018-11-30) + +## 1.2.5 (2018-11-22) + +## 1.2.4 (2018-11-21) + +## 1.2.3 (2018-11-20) + +### Bug fixes + +- Link URL validation now works correctly when a URL includes a fragment. Previously any URL containing a fragment failed validation. +- Link URL validation catches an incorrect number of forward slashes following a url using the http protocol. + +## 1.2.2 (2018-11-15) + +## 1.2.1 (2018-11-12) + +## 1.2.0 (2018-11-12) + +## New Feature + +- Add URL validation to links. + +## 1.1.1 (2018-11-09) + +## 1.1.0 (2018-11-09) + +### New Features + +- Import Format API components instead of passing as props. + +## 1.0.3 (2018-11-03) + +## 1.0.2 (2018-10-30) + +## 1.0.1 (2018-10-30) + +## 1.0.0 (2018-10-29) + +Initial release. diff --git a/gb-src/packages/format-library/README.md b/gb-src/packages/format-library/README.md new file mode 100644 index 0000000000000..69d6f157279d0 --- /dev/null +++ b/gb-src/packages/format-library/README.md @@ -0,0 +1,15 @@ +# Format library + +Format library for the WordPress editor. + +## Installation + +Install the module + +```bash +npm install @wordpress/format-library --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +

Code is Poetry.

diff --git a/gb-src/packages/format-library/package.json b/gb-src/packages/format-library/package.json new file mode 100644 index 0000000000000..3c03298e2f687 --- /dev/null +++ b/gb-src/packages/format-library/package.json @@ -0,0 +1,39 @@ +{ + "name": "@wordpress/format-library", + "version": "1.9.7", + "description": "Format library for the WordPress editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "formats" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/format-library/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/format-library" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/block-editor": "file:../block-editor", + "@wordpress/components": "file:../components", + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", + "@wordpress/i18n": "file:../i18n", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", + "@wordpress/url": "file:../url", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/format-library/src/bold/index.js b/gb-src/packages/format-library/src/bold/index.js new file mode 100644 index 0000000000000..a97cb170cca2c --- /dev/null +++ b/gb-src/packages/format-library/src/bold/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { toggleFormat } from '@wordpress/rich-text'; +import { RichTextToolbarButton, RichTextShortcut, __unstableRichTextInputEvent } from '@wordpress/block-editor'; + +const name = 'core/bold'; +const title = __( 'Bold' ); + +export const bold = { + name, + title, + tagName: 'strong', + className: null, + edit( { isActive, value, onChange } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + <> + + + <__unstableRichTextInputEvent + inputType="formatBold" + onInput={ onToggle } + /> + + ); + }, +}; diff --git a/gb-src/packages/format-library/src/code/index.js b/gb-src/packages/format-library/src/code/index.js new file mode 100644 index 0000000000000..4d7f752200839 --- /dev/null +++ b/gb-src/packages/format-library/src/code/index.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { toggleFormat, remove, applyFormat } from '@wordpress/rich-text'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; + +const name = 'core/code'; +const title = __( 'Inline Code' ); + +export const code = { + name, + title, + tagName: 'code', + className: null, + __unstableInputRule( value ) { + const BACKTICK = '`'; + const { start, text } = value; + const characterBefore = text.slice( start - 1, start ); + + // Quick check the text for the necessary character. + if ( characterBefore !== BACKTICK ) { + return value; + } + + const textBefore = text.slice( 0, start - 1 ); + const indexBefore = textBefore.lastIndexOf( BACKTICK ); + + if ( indexBefore === -1 ) { + return value; + } + + const startIndex = indexBefore; + const endIndex = start - 2; + + if ( startIndex === endIndex ) { + return value; + } + + value = remove( value, startIndex, startIndex + 1 ); + value = remove( value, endIndex, endIndex + 1 ); + value = applyFormat( value, { type: name }, startIndex, endIndex ); + + return value; + }, + edit( { value, onChange, isActive } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + ); + }, +}; diff --git a/gb-src/packages/format-library/src/default-formats.js b/gb-src/packages/format-library/src/default-formats.js new file mode 100644 index 0000000000000..f5d57910a7f13 --- /dev/null +++ b/gb-src/packages/format-library/src/default-formats.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { bold } from './bold'; +import { code } from './code'; +import { image } from './image'; +import { italic } from './italic'; +import { link } from './link'; +import { strikethrough } from './strikethrough'; +import { underline } from './underline'; + +export default [ + bold, + code, + image, + italic, + link, + strikethrough, + underline, +]; diff --git a/gb-src/packages/format-library/src/default-formats.native.js b/gb-src/packages/format-library/src/default-formats.native.js new file mode 100644 index 0000000000000..4c822c0ea649b --- /dev/null +++ b/gb-src/packages/format-library/src/default-formats.native.js @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { bold } from './bold'; +import { italic } from './italic'; +import { link } from './link'; +import { strikethrough } from './strikethrough'; + +export default [ + bold, + italic, + link, + strikethrough, +]; diff --git a/gb-src/packages/format-library/src/image/index.js b/gb-src/packages/format-library/src/image/index.js new file mode 100644 index 0000000000000..15cea7ecee4e3 --- /dev/null +++ b/gb-src/packages/format-library/src/image/index.js @@ -0,0 +1,174 @@ +/** + * WordPress dependencies + */ +import { Path, SVG, TextControl, Popover, IconButton } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Component, useMemo } from '@wordpress/element'; +import { insertObject } from '@wordpress/rich-text'; +import { MediaUpload, RichTextToolbarButton, MediaUploadCheck } from '@wordpress/block-editor'; +import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { computeCaretRect } from '@wordpress/dom'; + +const ALLOWED_MEDIA_TYPES = [ 'image' ]; + +const name = 'core/image'; +const title = __( 'Inline image' ); + +const stopKeyPropagation = ( event ) => event.stopPropagation(); + +const PopoverAtImage = ( { dependencies, ...props } ) => { + return ( + computeCaretRect(), dependencies ) } + { ...props } + /> + ); +}; + +export const image = { + name, + title, + keywords: [ __( 'photo' ), __( 'media' ) ], + object: true, + tagName: 'img', + className: null, + attributes: { + className: 'class', + style: 'style', + url: 'src', + alt: 'alt', + }, + edit: class ImageEdit extends Component { + constructor() { + super( ...arguments ); + this.onChange = this.onChange.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.openModal = this.openModal.bind( this ); + this.closeModal = this.closeModal.bind( this ); + this.state = { + modal: false, + }; + } + + static getDerivedStateFromProps( props, state ) { + const { activeObjectAttributes: { style } } = props; + + if ( style === state.previousStyle ) { + return null; + } + + if ( ! style ) { + return { + width: undefined, + previousStyle: style, + }; + } + + return { + width: style.replace( /\D/g, '' ), + previousStyle: style, + }; + } + + onChange( width ) { + this.setState( { width } ); + } + + onKeyDown( event ) { + if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { + // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. + event.stopPropagation(); + } + } + + openModal() { + this.setState( { modal: true } ); + } + + closeModal() { + this.setState( { modal: false } ); + } + + render() { + const { value, onChange, isObjectActive, activeObjectAttributes } = this.props; + const { style } = activeObjectAttributes; + + return ( + + } + title={ title } + onClick={ this.openModal } + isActive={ isObjectActive } + /> + { this.state.modal && { + this.closeModal(); + onChange( insertObject( value, { + type: name, + attributes: { + className: `wp-image-${ id }`, + style: `width: ${ Math.min( width, 150 ) }px;`, + url, + alt, + }, + } ) ); + } } + onClose={ this.closeModal } + render={ ( { open } ) => { + open(); + return null; + } } + /> } + { isObjectActive && + + { // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ } +
{ + const newReplacements = value.replacements.slice(); + + newReplacements[ value.start ] = { + type: name, + attributes: { + ...activeObjectAttributes, + style: `width: ${ this.state.width }px;`, + }, + }; + + onChange( { + ...value, + replacements: newReplacements, + } ); + + event.preventDefault(); + } } + > + + + + { /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ } +
+ } +
+ ); + } + }, +}; diff --git a/gb-src/packages/format-library/src/image/style.scss b/gb-src/packages/format-library/src/image/style.scss new file mode 100644 index 0000000000000..93bfc96ae7a16 --- /dev/null +++ b/gb-src/packages/format-library/src/image/style.scss @@ -0,0 +1,21 @@ +.block-editor-format-toolbar__image-container-content { + display: flex; + + .components-icon-button { + height: $icon-button-size + $grid-size + $grid-size; + align-self: flex-end; + } +} + +.block-editor-format-toolbar__image-container-value { + margin: $grid-size - $border-width; + flex-grow: 1; + flex-shrink: 1; + white-space: nowrap; + min-width: 150px; + max-width: 500px; + + &.components-base-control .components-base-control__field { + margin-bottom: 0; + } +} diff --git a/gb-src/packages/format-library/src/index.js b/gb-src/packages/format-library/src/index.js new file mode 100644 index 0000000000000..d3341b31315ee --- /dev/null +++ b/gb-src/packages/format-library/src/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { + registerFormatType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import formats from './default-formats'; + +formats.forEach( ( { name, ...settings } ) => registerFormatType( name, settings ) ); diff --git a/gb-src/packages/format-library/src/italic/index.js b/gb-src/packages/format-library/src/italic/index.js new file mode 100644 index 0000000000000..25c586a58dbc5 --- /dev/null +++ b/gb-src/packages/format-library/src/italic/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { toggleFormat } from '@wordpress/rich-text'; +import { RichTextToolbarButton, RichTextShortcut, __unstableRichTextInputEvent } from '@wordpress/block-editor'; + +const name = 'core/italic'; +const title = __( 'Italic' ); + +export const italic = { + name, + title, + tagName: 'em', + className: null, + edit( { isActive, value, onChange } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + <> + + + <__unstableRichTextInputEvent + inputType="formatItalic" + onInput={ onToggle } + /> + + ); + }, +}; diff --git a/gb-src/packages/format-library/src/link/index.js b/gb-src/packages/format-library/src/link/index.js new file mode 100644 index 0000000000000..e1390fbdf5d53 --- /dev/null +++ b/gb-src/packages/format-library/src/link/index.js @@ -0,0 +1,138 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { withSpokenMessages } from '@wordpress/components'; +import { + getTextContent, + applyFormat, + removeFormat, + slice, + isCollapsed, +} from '@wordpress/rich-text'; +import { isURL, isEmail } from '@wordpress/url'; +import { RichTextToolbarButton, RichTextShortcut } from '@wordpress/block-editor'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import InlineLinkUI from './inline'; + +const name = 'core/link'; +const title = __( 'Link' ); + +export const link = { + name, + title, + tagName: 'a', + className: null, + attributes: { + url: 'href', + target: 'target', + }, + __unstablePasteRule( value, { html, plainText } ) { + if ( isCollapsed( value ) ) { + return value; + } + + const pastedText = ( html || plainText ).replace( /<[^>]+>/g, '' ).trim(); + + // A URL was pasted, turn the selection into a link + if ( ! isURL( pastedText ) ) { + return value; + } + + // Allows us to ask for this information when we get a report. + window.console.log( 'Created link:\n\n', pastedText ); + + return applyFormat( value, { + type: name, + attributes: { + url: decodeEntities( pastedText ), + }, + } ); + }, + edit: withSpokenMessages( class LinkEdit extends Component { + constructor() { + super( ...arguments ); + + this.addLink = this.addLink.bind( this ); + this.stopAddingLink = this.stopAddingLink.bind( this ); + this.onRemoveFormat = this.onRemoveFormat.bind( this ); + this.state = { + addingLink: false, + }; + } + + addLink() { + const { value, onChange } = this.props; + const text = getTextContent( slice( value ) ); + + if ( text && isURL( text ) ) { + onChange( applyFormat( value, { type: name, attributes: { url: text } } ) ); + } else if ( text && isEmail( text ) ) { + onChange( applyFormat( value, { type: name, attributes: { url: `mailto:${ text }` } } ) ); + } else { + this.setState( { addingLink: true } ); + } + } + + stopAddingLink() { + this.setState( { addingLink: false } ); + } + + onRemoveFormat() { + const { value, onChange, speak } = this.props; + + onChange( removeFormat( value, name ) ); + speak( __( 'Link removed.' ), 'assertive' ); + } + + render() { + const { isActive, activeAttributes, value, onChange } = this.props; + + return ( + <> + + + { isActive && } + { ! isActive && } + + + ); + } + } ), +}; diff --git a/gb-src/packages/format-library/src/link/index.native.js b/gb-src/packages/format-library/src/link/index.native.js new file mode 100644 index 0000000000000..8457682d07d28 --- /dev/null +++ b/gb-src/packages/format-library/src/link/index.native.js @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import { find } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { withSpokenMessages } from '@wordpress/components'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; +import { + applyFormat, + getActiveFormat, + getTextContent, + isCollapsed, + removeFormat, + slice, +} from '@wordpress/rich-text'; +import { isURL } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import ModalLinkUI from './modal'; + +const name = 'core/link'; + +export const link = { + name, + title: __( 'Link' ), + tagName: 'a', + className: null, + attributes: { + url: 'href', + target: 'target', + }, + edit: withSpokenMessages( class LinkEdit extends Component { + constructor() { + super( ...arguments ); + + this.addLink = this.addLink.bind( this ); + this.stopAddingLink = this.stopAddingLink.bind( this ); + this.onRemoveFormat = this.onRemoveFormat.bind( this ); + this.state = { + addingLink: false, + }; + } + + addLink() { + const { value, onChange } = this.props; + const text = getTextContent( slice( value ) ); + + if ( text && isURL( text ) ) { + onChange( applyFormat( value, { type: name, attributes: { url: text } } ) ); + } else { + this.setState( { addingLink: true } ); + } + } + + stopAddingLink() { + this.setState( { addingLink: false } ); + } + + getLinkSelection() { + const { value, isActive } = this.props; + const startFormat = getActiveFormat( value, 'core/link' ); + + // if the link isn't selected, get the link manually by looking around the cursor + // TODO: handle partly selected links + if ( startFormat && isCollapsed( value ) && isActive ) { + let startIndex = value.start; + let endIndex = value.end; + + while ( find( value.formats[ startIndex ], startFormat ) ) { + startIndex--; + } + + endIndex++; + + while ( find( value.formats[ endIndex ], startFormat ) ) { + endIndex++; + } + + return { + ...value, + start: startIndex + 1, + end: endIndex, + }; + } + + return value; + } + + onRemoveFormat() { + const { onChange, speak } = this.props; + const linkSelection = this.getLinkSelection(); + + onChange( removeFormat( linkSelection, name ) ); + speak( __( 'Link removed.' ), 'assertive' ); + } + + render() { + const { isActive, activeAttributes, onChange } = this.props; + const linkSelection = this.getLinkSelection(); + + return ( + <> + + + + ); + } + } ), +}; diff --git a/gb-src/packages/format-library/src/link/inline.js b/gb-src/packages/format-library/src/link/inline.js new file mode 100644 index 0000000000000..bf1784db2132d --- /dev/null +++ b/gb-src/packages/format-library/src/link/inline.js @@ -0,0 +1,238 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef, useMemo } from '@wordpress/element'; +import { + ToggleControl, + withSpokenMessages, +} from '@wordpress/components'; +import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { getRectangleFromRange } from '@wordpress/dom'; +import { prependHTTP } from '@wordpress/url'; +import { + create, + insert, + isCollapsed, + applyFormat, + getTextContent, + slice, +} from '@wordpress/rich-text'; +import { URLPopover } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { createLinkFormat, isValidHref } from './utils'; + +const stopKeyPropagation = ( event ) => event.stopPropagation(); + +function isShowingInput( props, state ) { + return props.addingLink || state.editLink; +} + +const URLPopoverAtLink = ( { isActive, addingLink, value, ...props } ) => { + const anchorRect = useMemo( () => { + const selection = window.getSelection(); + const range = selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null; + if ( ! range ) { + return; + } + + if ( addingLink ) { + return getRectangleFromRange( range ); + } + + let element = range.startContainer; + + // If the caret is right before the element, select the next element. + element = element.nextElementSibling || element; + + while ( element.nodeType !== window.Node.ELEMENT_NODE ) { + element = element.parentNode; + } + + const closest = element.closest( 'a' ); + if ( closest ) { + return closest.getBoundingClientRect(); + } + }, [ isActive, addingLink, value.start, value.end ] ); + + if ( ! anchorRect ) { + return null; + } + + return ; +}; + +class InlineLinkUI extends Component { + constructor() { + super( ...arguments ); + + this.editLink = this.editLink.bind( this ); + this.submitLink = this.submitLink.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.onChangeInputValue = this.onChangeInputValue.bind( this ); + this.setLinkTarget = this.setLinkTarget.bind( this ); + this.onFocusOutside = this.onFocusOutside.bind( this ); + this.resetState = this.resetState.bind( this ); + this.autocompleteRef = createRef(); + + this.state = { + opensInNewWindow: false, + inputValue: '', + }; + } + + static getDerivedStateFromProps( props, state ) { + const { activeAttributes: { url, target } } = props; + const opensInNewWindow = target === '_blank'; + + if ( ! isShowingInput( props, state ) ) { + const update = {}; + if ( url !== state.inputValue ) { + update.inputValue = url; + } + + if ( opensInNewWindow !== state.opensInNewWindow ) { + update.opensInNewWindow = opensInNewWindow; + } + return Object.keys( update ).length ? update : null; + } + + return null; + } + + onKeyDown( event ) { + if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { + // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. + event.stopPropagation(); + } + } + + onChangeInputValue( inputValue ) { + this.setState( { inputValue } ); + } + + setLinkTarget( opensInNewWindow ) { + const { activeAttributes: { url = '' }, value, onChange } = this.props; + + this.setState( { opensInNewWindow } ); + + // Apply now if URL is not being edited. + if ( ! isShowingInput( this.props, this.state ) ) { + const selectedText = getTextContent( slice( value ) ); + + onChange( applyFormat( value, createLinkFormat( { + url, + opensInNewWindow, + text: selectedText, + } ) ) ); + } + } + + editLink( event ) { + this.setState( { editLink: true } ); + event.preventDefault(); + } + + submitLink( event ) { + const { isActive, value, onChange, speak } = this.props; + const { inputValue, opensInNewWindow } = this.state; + const url = prependHTTP( inputValue ); + const selectedText = getTextContent( slice( value ) ); + const format = createLinkFormat( { + url, + opensInNewWindow, + text: selectedText, + } ); + + event.preventDefault(); + + if ( isCollapsed( value ) && ! isActive ) { + const toInsert = applyFormat( create( { text: url } ), format, 0, url.length ); + onChange( insert( value, toInsert ) ); + } else { + onChange( applyFormat( value, format ) ); + } + + this.resetState(); + + if ( ! isValidHref( url ) ) { + speak( __( 'Warning: the link has been inserted but may have errors. Please test it.' ), 'assertive' ); + } else if ( isActive ) { + speak( __( 'Link edited.' ), 'assertive' ); + } else { + speak( __( 'Link inserted.' ), 'assertive' ); + } + } + + onFocusOutside() { + // The autocomplete suggestions list renders in a separate popover (in a portal), + // so onFocusOutside fails to detect that a click on a suggestion occurred in the + // LinkContainer. Detect clicks on autocomplete suggestions using a ref here, and + // return to avoid the popover being closed. + const autocompleteElement = this.autocompleteRef.current; + if ( autocompleteElement && autocompleteElement.contains( document.activeElement ) ) { + return; + } + + this.resetState(); + } + + resetState() { + this.props.stopAddingLink(); + this.setState( { editLink: false } ); + } + + render() { + const { isActive, activeAttributes: { url }, addingLink, value } = this.props; + + if ( ! isActive && ! addingLink ) { + return null; + } + + const { inputValue, opensInNewWindow } = this.state; + const showInput = isShowingInput( this.props, this.state ); + + return ( + ( + + ) } + > + { showInput ? ( + + ) : ( + + ) } + + ); + } +} + +export default withSpokenMessages( InlineLinkUI ); diff --git a/gb-src/packages/format-library/src/link/modal.native.js b/gb-src/packages/format-library/src/link/modal.native.js new file mode 100644 index 0000000000000..e83f78c5255e5 --- /dev/null +++ b/gb-src/packages/format-library/src/link/modal.native.js @@ -0,0 +1,174 @@ +/** + * External dependencies + */ +import React from 'react'; +import { Platform } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { prependHTTP } from '@wordpress/url'; +import { + BottomSheet, + withSpokenMessages, +} from '@wordpress/components'; +import { + create, + insert, + isCollapsed, + applyFormat, + getTextContent, + slice, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { createLinkFormat, isValidHref } from './utils'; + +import styles from './modal.scss'; + +class ModalLinkUI extends Component { + constructor() { + super( ...arguments ); + + this.submitLink = this.submitLink.bind( this ); + this.onChangeInputValue = this.onChangeInputValue.bind( this ); + this.onChangeText = this.onChangeText.bind( this ); + this.onChangeOpensInNewWindow = this.onChangeOpensInNewWindow.bind( this ); + this.removeLink = this.removeLink.bind( this ); + this.onDismiss = this.onDismiss.bind( this ); + + this.state = { + inputValue: '', + text: '', + opensInNewWindow: false, + }; + } + + componentDidUpdate( oldProps ) { + if ( oldProps === this.props ) { + return; + } + + const { activeAttributes: { url, target } } = this.props; + const opensInNewWindow = target === '_blank'; + + this.setState( { + inputValue: url || '', + text: getTextContent( slice( this.props.value ) ), + opensInNewWindow, + } ); + } + + onChangeInputValue( inputValue ) { + this.setState( { inputValue } ); + } + + onChangeText( text ) { + this.setState( { text } ); + } + + onChangeOpensInNewWindow( opensInNewWindow ) { + this.setState( { opensInNewWindow } ); + } + + submitLink() { + const { isActive, onChange, speak, value } = this.props; + const { inputValue, opensInNewWindow, text } = this.state; + const url = prependHTTP( inputValue ); + const linkText = text || inputValue; + const format = createLinkFormat( { + url, + opensInNewWindow, + text: linkText, + } ); + + if ( isCollapsed( value ) && ! isActive ) { // insert link + const toInsert = applyFormat( create( { text: linkText } ), format, 0, linkText.length ); + const newAttributes = insert( value, toInsert ); + onChange( { ...newAttributes, needsSelectionUpdate: true } ); + } else if ( text !== getTextContent( slice( value ) ) ) { // edit text in selected link + const toInsert = applyFormat( create( { text } ), format, 0, text.length ); + const newAttributes = insert( value, toInsert, value.start, value.end ); + onChange( { ...newAttributes, needsSelectionUpdate: true } ); + } else { // transform selected text into link + const newAttributes = applyFormat( value, format ); + onChange( { ...newAttributes, needsSelectionUpdate: true } ); + } + + if ( ! isValidHref( url ) ) { + speak( __( 'Warning: the link has been inserted but may have errors. Please test it.' ), 'assertive' ); + } else if ( isActive ) { + speak( __( 'Link edited.' ), 'assertive' ); + } else { + speak( __( 'Link inserted' ), 'assertive' ); + } + + this.props.onClose(); + } + + removeLink() { + this.props.onRemove(); + this.props.onClose(); + } + + onDismiss() { + if ( this.state.inputValue === '' ) { + this.removeLink(); + } else { + this.submitLink(); + } + } + + render() { + const { isVisible } = this.props; + const { text } = this.state; + + return ( + + { /* eslint-disable jsx-a11y/no-autofocus */ + + /* eslint-enable jsx-a11y/no-autofocus */ } + + + + + ); + } +} + +export default withSpokenMessages( ModalLinkUI ); diff --git a/gb-src/packages/format-library/src/link/modal.native.scss b/gb-src/packages/format-library/src/link/modal.native.scss new file mode 100644 index 0000000000000..e6a590eab85f7 --- /dev/null +++ b/gb-src/packages/format-library/src/link/modal.native.scss @@ -0,0 +1,3 @@ +.clearLinkButton { + color: $alert-red; +} diff --git a/gb-src/packages/format-library/src/link/style.scss b/gb-src/packages/format-library/src/link/style.scss new file mode 100644 index 0000000000000..71b417af9b5fb --- /dev/null +++ b/gb-src/packages/format-library/src/link/style.scss @@ -0,0 +1,18 @@ +.block-editor-format-toolbar__link-container-content { + display: flex; +} + +.block-editor-format-toolbar__link-container-value { + margin: $grid-size - $border-width; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 150px; + max-width: 500px; + + &.has-invalid-link { + color: $alert-red; + } +} diff --git a/gb-src/packages/format-library/src/link/test/inline.js b/gb-src/packages/format-library/src/link/test/inline.js new file mode 100644 index 0000000000000..fa5201992c10b --- /dev/null +++ b/gb-src/packages/format-library/src/link/test/inline.js @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ +import InlineLinkUI from '../inline'; +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +describe( 'InlineLinkUI', () => { + it( 'InlineLinkUI renders', () => { + const wrapper = shallow( + + ); + expect( wrapper ).toBeTruthy(); + } ); + + it( 'should set state.opensInNewWindow to false by default', () => { + const wrapper = shallow( + + ).dive(); + + expect( wrapper.state( 'opensInNewWindow' ) ).toEqual( false ); + } ); + + it( 'should set state.opensInNewWindow to true if props.activeAttributes.target is _blank', () => { + const givenProps = { + addingLink: false, + activeAttributes: { url: 'http://www.google.com', target: '_blank' }, + }; + + const wrapper = shallow( + + ).dive(); + wrapper.setProps( givenProps ); + expect( wrapper.state( 'opensInNewWindow' ) ).toEqual( true ); + } ); +} ); diff --git a/gb-src/packages/format-library/src/link/test/modal.native.js b/gb-src/packages/format-library/src/link/test/modal.native.js new file mode 100644 index 0000000000000..03594a4ef1c38 --- /dev/null +++ b/gb-src/packages/format-library/src/link/test/modal.native.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import ModalLinkUI from '../modal'; +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +describe( 'LinksUI', () => { + it( 'LinksUI renders', () => { + const wrapper = shallow( + + ); + expect( wrapper ).toBeTruthy(); + } ); + + it( 'Links are removed when no text is in the URL field', () => { + // Given + const onRemove = jest.fn(); + const wrapper = shallow( + + ).dive(); // -> dive() removes the HOC layer that was blocking access to ModalLinkUI + + // When + + // Close the BottomSheet + const bottomSheet = wrapper.find( 'BottomSheet' ).first(); + bottomSheet.simulate( 'close' ); + + // Then + + expect( onRemove ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'Links are saved when URL field has content', () => { + // Given + const onRemove = jest.fn(); + const wrapper = shallow( + + ).dive(); // -> dive() removes the HOC layer that was blocking access to ModalLinkUI + + // Mock `submitLink` for simplicity (we don't want to test submitLink itself here) + wrapper.instance().submitLink = jest.fn(); + + // When + + // Simulate user typing on the URL Cell. + const bottomSheet = wrapper.find( 'BottomSheet' ).first(); + const cell = bottomSheet.find( 'BottomSheetCell' ).first(); + cell.simulate( 'changeValue', 'wordpress.com' ); + + // Close the BottomSheet + bottomSheet.simulate( 'close' ); + + // Then + expect( onRemove ).toHaveBeenCalledTimes( 0 ); + expect( wrapper.instance().submitLink ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/gb-src/packages/format-library/src/link/test/utils.js b/gb-src/packages/format-library/src/link/test/utils.js new file mode 100644 index 0000000000000..aec4af1458888 --- /dev/null +++ b/gb-src/packages/format-library/src/link/test/utils.js @@ -0,0 +1,79 @@ + +/** + * Internal dependencies + */ +import { + isValidHref, +} from '../utils'; + +describe( 'isValidHref', () => { + it( 'returns true if the href cannot be recognised as a url or an anchor link', () => { + expect( isValidHref( 'notaurloranchorlink' ) ).toBe( true ); + } ); + + it( 'returns false if the href is not specified', () => { + expect( isValidHref() ).toBe( false ); + expect( isValidHref( '' ) ).toBe( false ); + expect( isValidHref( ' ' ) ).toBe( false ); + } ); + + describe( 'URLs beginning with a protocol', () => { + it( 'returns true for valid URLs', () => { + expect( isValidHref( 'tel:+123456789' ) ).toBe( true ); + expect( isValidHref( 'mailto:test@somewhere.com' ) ).toBe( true ); + expect( isValidHref( 'file:///c:/WINDOWS/winamp.exe' ) ).toBe( true ); + expect( isValidHref( 'http://test.com' ) ).toBe( true ); + expect( isValidHref( 'https://test.com' ) ).toBe( true ); + expect( isValidHref( 'http://test-with-hyphen.com' ) ).toBe( true ); + expect( isValidHref( 'http://test.com/' ) ).toBe( true ); + expect( isValidHref( 'http://test.com#fragment' ) ).toBe( true ); + expect( isValidHref( 'http://test.com/path#fragment' ) ).toBe( true ); + expect( isValidHref( 'http://test.com/with/path/separators' ) ).toBe( true ); + expect( isValidHref( 'http://test.com/with?query=string¶ms' ) ).toBe( true ); + } ); + + it( 'returns false for invalid urls', () => { + expect( isValidHref( 'tel:+12 345 6789' ) ).toBe( false ); + expect( isValidHref( 'mailto:test @ somewhere.com' ) ).toBe( false ); + expect( isValidHref( 'mailto: test@somewhere.com' ) ).toBe( false ); + expect( isValidHref( 'ht#tp://this/is/invalid' ) ).toBe( false ); + expect( isValidHref( 'ht#tp://th&is/is/invalid' ) ).toBe( false ); + expect( isValidHref( 'http:/test.com' ) ).toBe( false ); + expect( isValidHref( 'http://?test.com' ) ).toBe( false ); + expect( isValidHref( 'http://#test.com' ) ).toBe( false ); + expect( isValidHref( 'http://test.com?double?params' ) ).toBe( false ); + expect( isValidHref( 'http://test.com#double#anchor' ) ).toBe( false ); + expect( isValidHref( 'http://test.com?path/after/params' ) ).toBe( false ); + expect( isValidHref( 'http://test.com#path/after/fragment' ) ).toBe( false ); + } ); + + it( 'returns false if the URL has whitespace', () => { + expect( isValidHref( 'http:/ /test.com' ) ).toBe( false ); + expect( isValidHref( 'http://te st.com' ) ).toBe( false ); + expect( isValidHref( 'http:// test.com' ) ).toBe( false ); + expect( isValidHref( 'http://test.c om' ) ).toBe( false ); + expect( isValidHref( 'http://test.com/ee ee/' ) ).toBe( false ); + expect( isValidHref( 'http://test.com/eeee?qwd qwdw' ) ).toBe( false ); + expect( isValidHref( 'http://test.com/eeee#qwd qwdw' ) ).toBe( false ); + } ); + } ); + + describe( 'Anchor links', () => { + it( 'returns true for valid anchor links', () => { + expect( isValidHref( '#yesitis' ) ).toBe( true ); + expect( isValidHref( '#yes_it_is' ) ).toBe( true ); + expect( isValidHref( '#yes~it~is' ) ).toBe( true ); + expect( isValidHref( '#yes-it-is' ) ).toBe( true ); + } ); + + it( 'returns false for invalid anchor links', () => { + expect( isValidHref( '' ) ).toBe( false ); + expect( isValidHref( '#no-it-isnt#' ) ).toBe( false ); + expect( isValidHref( '#no-it-#isnt' ) ).toBe( false ); + expect( isValidHref( '#no-it-isnt?' ) ).toBe( false ); + expect( isValidHref( '#no-it isnt' ) ).toBe( false ); + expect( isValidHref( '#no-it-isnt/' ) ).toBe( false ); + } ); + } ); +} ); + diff --git a/gb-src/packages/format-library/src/link/utils.js b/gb-src/packages/format-library/src/link/utils.js new file mode 100644 index 0000000000000..58f64ab71ce41 --- /dev/null +++ b/gb-src/packages/format-library/src/link/utils.js @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { startsWith } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + getProtocol, + isValidProtocol, + getAuthority, + isValidAuthority, + getPath, + isValidPath, + getQueryString, + isValidQueryString, + getFragment, + isValidFragment, +} from '@wordpress/url'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Check for issues with the provided href. + * + * @param {string} href The href. + * + * @return {boolean} Is the href invalid? + */ +export function isValidHref( href ) { + if ( ! href ) { + return false; + } + + const trimmedHref = href.trim(); + + if ( ! trimmedHref ) { + return false; + } + + // Does the href start with something that looks like a URL protocol? + if ( /^\S+:/.test( trimmedHref ) ) { + const protocol = getProtocol( trimmedHref ); + if ( ! isValidProtocol( protocol ) ) { + return false; + } + + // Add some extra checks for http(s) URIs, since these are the most common use-case. + // This ensures URIs with an http protocol have exactly two forward slashes following the protocol. + if ( startsWith( protocol, 'http' ) && ! /^https?:\/\/[^\/\s]/i.test( trimmedHref ) ) { + return false; + } + + const authority = getAuthority( trimmedHref ); + if ( ! isValidAuthority( authority ) ) { + return false; + } + + const path = getPath( trimmedHref ); + if ( path && ! isValidPath( path ) ) { + return false; + } + + const queryString = getQueryString( trimmedHref ); + if ( queryString && ! isValidQueryString( queryString ) ) { + return false; + } + + const fragment = getFragment( trimmedHref ); + if ( fragment && ! isValidFragment( fragment ) ) { + return false; + } + } + + // Validate anchor links. + if ( startsWith( trimmedHref, '#' ) && ! isValidFragment( trimmedHref ) ) { + return false; + } + + return true; +} + +/** + * Generates the format object that will be applied to the link text. + * + * @param {Object} options + * @param {string} options.url The href of the link. + * @param {boolean} options.opensInNewWindow Whether this link will open in a new window. + * @param {Object} options.text The text that is being hyperlinked. + * + * @return {Object} The final format object. + */ +export function createLinkFormat( { url, opensInNewWindow, text } ) { + const format = { + type: 'core/link', + attributes: { + url, + }, + }; + + if ( opensInNewWindow ) { + // translators: accessibility label for external links, where the argument is the link text + const label = sprintf( __( '%s (opens in a new tab)' ), text ); + + format.attributes.target = '_blank'; + format.attributes.rel = 'noreferrer noopener'; + format.attributes[ 'aria-label' ] = label; + } + + return format; +} diff --git a/gb-src/packages/format-library/src/strikethrough/index.js b/gb-src/packages/format-library/src/strikethrough/index.js new file mode 100644 index 0000000000000..23c17f1fea4a8 --- /dev/null +++ b/gb-src/packages/format-library/src/strikethrough/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { toggleFormat } from '@wordpress/rich-text'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; + +const name = 'core/strikethrough'; +const title = __( 'Strikethrough' ); + +export const strikethrough = { + name, + title, + tagName: 's', + className: null, + edit( { isActive, value, onChange } ) { + const onToggle = () => onChange( toggleFormat( value, { type: name } ) ); + + return ( + + ); + }, +}; diff --git a/gb-src/packages/format-library/src/style.scss b/gb-src/packages/format-library/src/style.scss new file mode 100644 index 0000000000000..a9ab600a7ad7d --- /dev/null +++ b/gb-src/packages/format-library/src/style.scss @@ -0,0 +1,2 @@ +@import "./image/style.scss"; +@import "./link/style.scss"; diff --git a/gb-src/packages/format-library/src/underline/index.js b/gb-src/packages/format-library/src/underline/index.js new file mode 100644 index 0000000000000..d83cc2e3c484c --- /dev/null +++ b/gb-src/packages/format-library/src/underline/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { toggleFormat } from '@wordpress/rich-text'; +import { RichTextShortcut, __unstableRichTextInputEvent } from '@wordpress/block-editor'; + +const name = 'core/underline'; + +export const underline = { + name, + title: __( 'Underline' ), + tagName: 'span', + className: null, + attributes: { + style: 'style', + }, + edit( { value, onChange } ) { + const onToggle = () => { + onChange( + toggleFormat( value, { + type: name, + attributes: { + style: 'text-decoration: underline;', + }, + } ) ); + }; + + return ( + <> + + <__unstableRichTextInputEvent + inputType="formatUnderline" + onInput={ onToggle } + /> + + ); + }, +}; diff --git a/gb-src/packages/hooks/.npmrc b/gb-src/packages/hooks/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/hooks/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/hooks/CHANGELOG.md b/gb-src/packages/hooks/CHANGELOG.md new file mode 100644 index 0000000000000..acdcc9047ba28 --- /dev/null +++ b/gb-src/packages/hooks/CHANGELOG.md @@ -0,0 +1,41 @@ +## 2.6.0 (2019-08-29) + +### New Feature + + - Enable an optional namespace parameter for `hasAction` & `hasFilter`. When checking if an action or filter exists, `hasAction` and `hasFilter` now accept an optional paramter to limit matches by namespace. + +## 2.4.0 (2019-06-12) + +### New Feature + +- Enable support for the 'all' hook in non production environments. + +## 2.0.4 (2019-01-03) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +## 1.2.0 (2018-07-12) + +### New Feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Polish + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.1.8 (2018-05-08) + +### Polish + +- Documentation: Improve usage examples ([#121](https://github.com/WordPress/packages/pull/121)) + +## 1.1.6 (2018-03-21) + +### Bug Fix + +- Fix: Resolves issue where action argument would be undefined on all but the first action callback. diff --git a/gb-src/packages/hooks/README.md b/gb-src/packages/hooks/README.md new file mode 100644 index 0000000000000..4698580f4b4ed --- /dev/null +++ b/gb-src/packages/hooks/README.md @@ -0,0 +1,59 @@ +# Hooks + +A lightweight & efficient EventManager for JavaScript. + +## Installation + +Install the module + +```bash +npm install @wordpress/hooks --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +### Usage + +In your JavaScript project, use hooks as follows: +```javascript +import { createHooks } from '@wordpress/hooks'; + +myObject.hooks = createHooks(); +myObject.hooks.addAction(); //etc... +``` + +In the WordPress context, API functions can be called via the global `wp.hooks` like this `wp.hooks.addAction()`, etc. One notable difference from the PHP API is that `addAction()` and `addFilter()` also need to include a namespace as the second argument. + +### API Usage + +* `createHooks()` +* `addAction( 'hookName', 'namespace', callback, priority )` +* `addFilter( 'hookName', 'namespace', callback, priority )` +* `removeAction( 'hookName', 'namespace' )` +* `removeFilter( 'hookName', 'namespace' )` +* `removeAllActions( 'hookName' )` +* `removeAllFilters( 'hookName' )` +* `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )` +* `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )` +* `doingAction( 'hookName' )` +* `doingFilter( 'hookName' )` +* `didAction( 'hookName' )` +* `didFilter( 'hookName' )` +* `hasAction( 'hookName', 'namespace' )` +* `hasFilter( 'hookName', 'namespace' )` +* `actions` +* `filters` + + +### Events on action/filter add or remove + +Whenever an action or filter is added or removed, a matching `hookAdded` or `hookRemoved` action is triggered. + +* `hookAdded` action is triggered when `addFilter()` or `addAction()` method is called, passing values for `hookName`, `functionName`, `callback` and `priority`. +* `hookRemoved` action is triggered when `removeFilter()` or `removeAction()` method is called, passing values for `hookName` and `functionName`. + +### The `all` hook + +In non-minified builds developers can register a filter or action that will be called on *all* hooks, for example: `addAction( 'all', 'namespace', callbackFunction );`. Useful for debugging, the code supporting the `all` hook is stripped from the production code for performance reasons. + +

Code is Poetry.

diff --git a/gb-src/packages/hooks/benchmark/index.js b/gb-src/packages/hooks/benchmark/index.js new file mode 100644 index 0000000000000..e78b2827bb1ff --- /dev/null +++ b/gb-src/packages/hooks/benchmark/index.js @@ -0,0 +1,49 @@ +const Benchmark = require( 'benchmark' ); +const hooks = require( '../' ); + +const suite = new Benchmark.Suite; + +const filter = process.argv[ 2 ]; +const isInFilter = ( key ) => ! filter || filter === key; + +function reset() { + hooks.removeAllFilters( 'example' ); + hooks.removeAllActions( 'example' ); +} + +if ( isInFilter( 'applyFilters' ) ) { + function myCallback() {} + + hooks.addFilter( 'example', 'myCallback', myCallback ); + + suite + .add( 'applyFilters - handled', () => { + hooks.applyFilters( 'handled' ); + } ) + .add( 'applyFilters - unhandled', () => { + hooks.applyFilters( 'unhandled' ); + } ); +} + +if ( isInFilter( 'addFilter' ) ) { + let hasSetHighPriority = false; + + suite + .add( 'addFilter - append last', () => { + hooks.addFilter( 'example', 'myCallback', () => {} ); + } ) + .add( 'addFilter - default before higher priority', () => { + if ( ! hasSetHighPriority ) { + hasSetHighPriority = true; + hooks.addFilter( 'example', 'priority', () => {}, 20 ); + } + + hooks.addFilter( 'example', 'myCallback', () => {} ); + } ); +} + +suite + .on( 'cycle', reset ) + // eslint-disable-next-line no-console + .on( 'cycle', ( event ) => console.log( event.target.toString() ) ) + .run( { async: true } ); diff --git a/gb-src/packages/hooks/package.json b/gb-src/packages/hooks/package.json new file mode 100644 index 0000000000000..7ebe8dcdb449a --- /dev/null +++ b/gb-src/packages/hooks/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wordpress/hooks", + "version": "2.6.0", + "description": "WordPress hooks library.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "hooks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/hooks/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/hooks" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/hooks/src/createAddHook.js b/gb-src/packages/hooks/src/createAddHook.js new file mode 100644 index 0000000000000..79b3d6f0b91d3 --- /dev/null +++ b/gb-src/packages/hooks/src/createAddHook.js @@ -0,0 +1,90 @@ +/** + * Internal dependencies + */ +import validateNamespace from './validateNamespace.js'; +import validateHookName from './validateHookName.js'; +import { doAction } from './'; + +/** + * Returns a function which, when invoked, will add a hook. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * + * @return {Function} Function that adds a new hook. + */ +function createAddHook( hooks ) { + /** + * Adds the hook to the appropriate hooks container. + * + * @param {string} hookName Name of hook to add + * @param {string} namespace The unique namespace identifying the callback in the form `vendor/plugin/function`. + * @param {Function} callback Function to call when the hook is run + * @param {?number} priority Priority of this hook (default=10) + */ + return function addHook( hookName, namespace, callback, priority = 10 ) { + if ( ! validateHookName( hookName ) ) { + return; + } + + if ( ! validateNamespace( namespace ) ) { + return; + } + + if ( 'function' !== typeof callback ) { + // eslint-disable-next-line no-console + console.error( 'The hook callback must be a function.' ); + return; + } + + // Validate numeric priority + if ( 'number' !== typeof priority ) { + // eslint-disable-next-line no-console + console.error( 'If specified, the hook priority must be a number.' ); + return; + } + + const handler = { callback, priority, namespace }; + + if ( hooks[ hookName ] ) { + // Find the correct insert index of the new hook. + const handlers = hooks[ hookName ].handlers; + + let i; + for ( i = handlers.length; i > 0; i-- ) { + if ( priority >= handlers[ i - 1 ].priority ) { + break; + } + } + + if ( i === handlers.length ) { + // If append, operate via direct assignment. + handlers[ i ] = handler; + } else { + // Otherwise, insert before index via splice. + handlers.splice( i, 0, handler ); + } + + // We may also be currently executing this hook. If the callback + // we're adding would come after the current callback, there's no + // problem; otherwise we need to increase the execution index of + // any other runs by 1 to account for the added element. + ( hooks.__current || [] ).forEach( ( hookInfo ) => { + if ( hookInfo.name === hookName && hookInfo.currentIndex >= i ) { + hookInfo.currentIndex++; + } + } ); + } else { + // This is the first hook of its type. + hooks[ hookName ] = { + handlers: [ handler ], + runs: 0, + }; + } + + if ( hookName !== 'hookAdded' ) { + doAction( 'hookAdded', hookName, namespace, callback, priority ); + } + }; +} + +export default createAddHook; diff --git a/gb-src/packages/hooks/src/createCurrentHook.js b/gb-src/packages/hooks/src/createCurrentHook.js new file mode 100644 index 0000000000000..c36cab8b43df2 --- /dev/null +++ b/gb-src/packages/hooks/src/createCurrentHook.js @@ -0,0 +1,27 @@ +/** + * Returns a function which, when invoked, will return the name of the + * currently running hook, or `null` if no hook of the given type is currently + * running. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * + * @return {Function} Function that returns the current hook. + */ +function createCurrentHook( hooks ) { + /** + * Returns the name of the currently running hook, or `null` if no hook of + * the given type is currently running. + * + * @return {?string} The name of the currently running hook, or + * `null` if no hook is currently running. + */ + return function currentHook() { + if ( ! hooks.__current || ! hooks.__current.length ) { + return null; + } + + return hooks.__current[ hooks.__current.length - 1 ].name; + }; +} + +export default createCurrentHook; diff --git a/gb-src/packages/hooks/src/createDidHook.js b/gb-src/packages/hooks/src/createDidHook.js new file mode 100644 index 0000000000000..b5cf3cf687195 --- /dev/null +++ b/gb-src/packages/hooks/src/createDidHook.js @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import validateHookName from './validateHookName.js'; + +/** + * Returns a function which, when invoked, will return the number of times a + * hook has been called. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * + * @return {Function} Function that returns a hook's call count. + */ +function createDidHook( hooks ) { + /** + * Returns the number of times an action has been fired. + * + * @param {string} hookName The hook name to check. + * + * @return {number} The number of times the hook has run. + */ + return function didHook( hookName ) { + if ( ! validateHookName( hookName ) ) { + return; + } + + return hooks[ hookName ] && hooks[ hookName ].runs ? + hooks[ hookName ].runs : + 0; + }; +} + +export default createDidHook; diff --git a/gb-src/packages/hooks/src/createDoingHook.js b/gb-src/packages/hooks/src/createDoingHook.js new file mode 100644 index 0000000000000..c2bf6ba830e32 --- /dev/null +++ b/gb-src/packages/hooks/src/createDoingHook.js @@ -0,0 +1,32 @@ +/** + * Returns a function which, when invoked, will return whether a hook is + * currently being executed. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * + * @return {Function} Function that returns whether a hook is currently + * being executed. + */ +function createDoingHook( hooks ) { + /** + * Returns whether a hook is currently being executed. + * + * @param {?string} hookName The name of the hook to check for. If + * omitted, will check for any hook being executed. + * + * @return {boolean} Whether the hook is being executed. + */ + return function doingHook( hookName ) { + // If the hookName was not passed, check for any current hook. + if ( 'undefined' === typeof hookName ) { + return 'undefined' !== typeof hooks.__current[ 0 ]; + } + + // Return the __current hook. + return hooks.__current[ 0 ] ? + hookName === hooks.__current[ 0 ].name : + false; + }; +} + +export default createDoingHook; diff --git a/gb-src/packages/hooks/src/createHasHook.js b/gb-src/packages/hooks/src/createHasHook.js new file mode 100644 index 0000000000000..7e2474ccf8ae0 --- /dev/null +++ b/gb-src/packages/hooks/src/createHasHook.js @@ -0,0 +1,31 @@ +/** + * Returns a function which, when invoked, will return whether any handlers are + * attached to a particular hook. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * + * @return {Function} Function that returns whether any handlers are + * attached to a particular hook and optional namespace. + */ +function createHasHook( hooks ) { + /** + * Returns whether any handlers are attached for the given hookName and optional namespace. + * + * @param {string} hookName The name of the hook to check for. + * @param {?string} namespace Optional. The unique namespace identifying the callback + * in the form `vendor/plugin/function`. + * + * @return {boolean} Whether there are handlers that are attached to the given hook. + */ + return function hasHook( hookName, namespace ) { + // Use the namespace if provided. + if ( 'undefined' !== typeof namespace ) { + return hookName in hooks && + hooks[ hookName ].handlers.some( ( hook ) => hook.namespace === namespace ); + } + + return hookName in hooks; + }; +} + +export default createHasHook; diff --git a/gb-src/packages/hooks/src/createHooks.js b/gb-src/packages/hooks/src/createHooks.js new file mode 100644 index 0000000000000..d61effc36a4ac --- /dev/null +++ b/gb-src/packages/hooks/src/createHooks.js @@ -0,0 +1,45 @@ +/** + * Internal dependencies + */ +import createAddHook from './createAddHook'; +import createRemoveHook from './createRemoveHook'; +import createHasHook from './createHasHook'; +import createRunHook from './createRunHook'; +import createCurrentHook from './createCurrentHook'; +import createDoingHook from './createDoingHook'; +import createDidHook from './createDidHook'; + +/** + * Returns an instance of the hooks object. + * + * @return {Object} Object that contains all hooks. + */ +function createHooks() { + const actions = Object.create( null ); + const filters = Object.create( null ); + actions.__current = []; + filters.__current = []; + + return { + addAction: createAddHook( actions ), + addFilter: createAddHook( filters ), + removeAction: createRemoveHook( actions ), + removeFilter: createRemoveHook( filters ), + hasAction: createHasHook( actions ), + hasFilter: createHasHook( filters ), + removeAllActions: createRemoveHook( actions, true ), + removeAllFilters: createRemoveHook( filters, true ), + doAction: createRunHook( actions ), + applyFilters: createRunHook( filters, true ), + currentAction: createCurrentHook( actions ), + currentFilter: createCurrentHook( filters ), + doingAction: createDoingHook( actions ), + doingFilter: createDoingHook( filters ), + didAction: createDidHook( actions ), + didFilter: createDidHook( filters ), + actions, + filters, + }; +} + +export default createHooks; diff --git a/gb-src/packages/hooks/src/createRemoveHook.js b/gb-src/packages/hooks/src/createRemoveHook.js new file mode 100644 index 0000000000000..bbca6a6188e56 --- /dev/null +++ b/gb-src/packages/hooks/src/createRemoveHook.js @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import validateNamespace from './validateNamespace.js'; +import validateHookName from './validateHookName.js'; +import { doAction } from './'; + +/** + * Returns a function which, when invoked, will remove a specified hook or all + * hooks by the given name. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * @param {boolean} removeAll Whether to remove all callbacks for a hookName, without regard to namespace. Used to create `removeAll*` functions. + * + * @return {Function} Function that removes hooks. + */ +function createRemoveHook( hooks, removeAll ) { + /** + * Removes the specified callback (or all callbacks) from the hook with a + * given hookName and namespace. + * + * @param {string} hookName The name of the hook to modify. + * @param {string} namespace The unique namespace identifying the callback in the form `vendor/plugin/function`. + * + * @return {number} The number of callbacks removed. + */ + return function removeHook( hookName, namespace ) { + if ( ! validateHookName( hookName ) ) { + return; + } + + if ( ! removeAll && ! validateNamespace( namespace ) ) { + return; + } + + // Bail if no hooks exist by this name + if ( ! hooks[ hookName ] ) { + return 0; + } + + let handlersRemoved = 0; + + if ( removeAll ) { + handlersRemoved = hooks[ hookName ].handlers.length; + hooks[ hookName ] = { + runs: hooks[ hookName ].runs, + handlers: [], + }; + } else { + // Try to find the specified callback to remove. + const handlers = hooks[ hookName ].handlers; + for ( let i = handlers.length - 1; i >= 0; i-- ) { + if ( + handlers[ i ].namespace === namespace + ) { + handlers.splice( i, 1 ); + handlersRemoved++; + // This callback may also be part of a hook that is + // currently executing. If the callback we're removing + // comes after the current callback, there's no problem; + // otherwise we need to decrease the execution index of any + // other runs by 1 to account for the removed element. + ( hooks.__current || [] ).forEach( ( hookInfo ) => { + if ( hookInfo.name === hookName && hookInfo.currentIndex >= i ) { + hookInfo.currentIndex--; + } + } ); + } + } + } + if ( hookName !== 'hookRemoved' ) { + doAction( 'hookRemoved', hookName, namespace ); + } + + return handlersRemoved; + }; +} + +export default createRemoveHook; diff --git a/gb-src/packages/hooks/src/createRunHook.js b/gb-src/packages/hooks/src/createRunHook.js new file mode 100644 index 0000000000000..d9c909b734af7 --- /dev/null +++ b/gb-src/packages/hooks/src/createRunHook.js @@ -0,0 +1,73 @@ +/** + * Returns a function which, when invoked, will execute all callbacks + * registered to a hook of the specified type, optionally returning the final + * value of the call chain. + * + * @param {Object} hooks Stored hooks, keyed by hook name. + * @param {?boolean} returnFirstArg Whether each hook callback is expected to + * return its first argument. + * + * @return {Function} Function that runs hook callbacks. + */ +function createRunHook( hooks, returnFirstArg ) { + /** + * Runs all callbacks for the specified hook. + * + * @param {string} hookName The name of the hook to run. + * @param {...*} args Arguments to pass to the hook callbacks. + * + * @return {*} Return value of runner, if applicable. + */ + return function runHooks( hookName, ...args ) { + if ( ! hooks[ hookName ] ) { + hooks[ hookName ] = { + handlers: [], + runs: 0, + }; + } + + hooks[ hookName ].runs++; + + const handlers = hooks[ hookName ].handlers; + + // The following code is stripped from production builds. + if ( 'production' !== process.env.NODE_ENV ) { + // Handle any 'all' hooks registered. + if ( 'hookAdded' !== hookName && hooks.all ) { + handlers.push( ...hooks.all.handlers ); + } + } + + if ( ! handlers || ! handlers.length ) { + return returnFirstArg ? + args[ 0 ] : + undefined; + } + + const hookInfo = { + name: hookName, + currentIndex: 0, + }; + + hooks.__current.push( hookInfo ); + + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + + const result = handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + + hookInfo.currentIndex++; + } + + hooks.__current.pop(); + + if ( returnFirstArg ) { + return args[ 0 ]; + } + }; +} + +export default createRunHook; diff --git a/gb-src/packages/hooks/src/index.js b/gb-src/packages/hooks/src/index.js new file mode 100644 index 0000000000000..78a68df809086 --- /dev/null +++ b/gb-src/packages/hooks/src/index.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import createHooks from './createHooks'; + +const { + addAction, + addFilter, + removeAction, + removeFilter, + hasAction, + hasFilter, + removeAllActions, + removeAllFilters, + doAction, + applyFilters, + currentAction, + currentFilter, + doingAction, + doingFilter, + didAction, + didFilter, + actions, + filters, +} = createHooks(); + +export { + createHooks, + addAction, + addFilter, + removeAction, + removeFilter, + hasAction, + hasFilter, + removeAllActions, + removeAllFilters, + doAction, + applyFilters, + currentAction, + currentFilter, + doingAction, + doingFilter, + didAction, + didFilter, + actions, + filters, +}; diff --git a/gb-src/packages/hooks/src/test/index.test.js b/gb-src/packages/hooks/src/test/index.test.js new file mode 100644 index 0000000000000..26d04853ede06 --- /dev/null +++ b/gb-src/packages/hooks/src/test/index.test.js @@ -0,0 +1,777 @@ +/** + * Internal dependencies + */ +import { + createHooks, + addAction, + addFilter, + removeAction, + removeFilter, + hasAction, + hasFilter, + removeAllActions, + removeAllFilters, + doAction, + applyFilters, + currentAction, + currentFilter, + doingAction, + doingFilter, + didAction, + didFilter, + actions, + filters, +} from '..'; + +function filterA( str ) { + return str + 'a'; +} + +function filterB( str ) { + return str + 'b'; +} + +function filterC( str ) { + return str + 'c'; +} + +function filterCRemovesSelf( str ) { + removeFilter( 'test.filter', 'my_callback_filter_c_removes_self' ); + return str + 'b'; +} + +function filterRemovesB( str ) { + removeFilter( 'test.filter', 'my_callback_filter_b' ); + return str; +} + +function filterRemovesC( str ) { + removeFilter( 'test.filter', 'my_callback_filter_c' ); + return str; +} + +function actionA() { + window.actionValue += 'a'; +} + +function actionB() { + window.actionValue += 'b'; +} + +function actionC() { + window.actionValue += 'c'; +} + +beforeEach( () => { + window.actionValue = ''; + // Reset state in between tests (clear all callbacks, `didAction` counts, + // etc.) Just reseting actions and filters is not enough + // because the internal functions have references to the original objects. + [ actions, filters ].forEach( ( hooks ) => { + for ( const k in hooks ) { + if ( '__current' === k ) { + continue; + } + + delete hooks[ k ]; + } + delete hooks.all; + } ); +} ); + +test( 'hooks can be instantiated', () => { + const hooks = createHooks(); + + expect( typeof hooks ).toBe( 'object' ); +} ); + +test( 'run a filter with no callbacks', () => { + expect( applyFilters( 'test.filter', 42 ) ).toBe( 42 ); +} ); + +test( 'add and remove a filter', () => { + addFilter( 'test.filter', 'my_callback', filterA ); + expect( removeAllFilters( 'test.filter' ) ).toBe( 1 ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'test' ); + expect( removeAllFilters( 'test.filter' ) ).toBe( 0 ); +} ); + +test( 'add a filter and run it', () => { + addFilter( 'test.filter', 'my_callback', filterA ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testa' ); +} ); + +test( 'add 2 filters in a row and run them', () => { + addFilter( 'test.filter', 'my_callback', filterA ); + addFilter( 'test.filter', 'my_callback', filterB ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testab' ); +} ); + +test( 'remove a non-existent filter', () => { + expect( removeFilter( 'test.filter', 'my_callback', filterA ) ).toBe( 0 ); + expect( removeAllFilters( 'test.filter' ) ).toBe( 0 ); +} ); + +test( 'remove an invalid namespace from a filter', () => { + expect( removeFilter( 'test.filter', 42 ) ).toBe( undefined ); + expect( console ).toHaveErroredWith( + 'The namespace must be a non-empty string.' + ); +} ); + +test( 'cannot add filters with non-string hook names', () => { + addFilter( 42, 'my_callback', () => null ); + expect( console ).toHaveErroredWith( + 'The hook name must be a non-empty string.' + ); +} ); + +test( 'cannot add filters with empty-string hook names', () => { + addFilter( '', 'my_callback', () => null ); + expect( console ).toHaveErroredWith( + 'The hook name must be a non-empty string.' + ); +} ); + +test( 'cannot add filters with empty-string namespaces', () => { + addFilter( 'hook_name', '', () => null ); + expect( console ).toHaveErroredWith( + 'The namespace must be a non-empty string.' + ); +} ); + +test( 'cannot add filters with invalid namespaces', () => { + addFilter( 'hook_name', 'invalid_%&name', () => null ); + expect( console ).toHaveErroredWith( + 'The namespace can only contain numbers, letters, dashes, periods, underscores and slashes.' + ); +} ); + +test( 'cannot add filters with namespaces starting with a slash', () => { + addFilter( 'hook_name', '/invalid_name', () => null ); + expect( console ).toHaveErroredWith( + 'The namespace can only contain numbers, letters, dashes, periods, underscores and slashes.' + ); +} ); + +test( 'Can add filters with dashes in namespaces', () => { + addFilter( 'hook_name', 'with-dashes', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'Can add filters with capitals in namespaces', () => { + addFilter( 'hook_name', 'My_Name-OhNoaction', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'Can add filters with slashes in namespaces', () => { + addFilter( 'hook_name', 'my/name/action', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'Can add filters with periods in namespaces', () => { + addFilter( 'hook_name', 'my.name.action', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'Can add filters with capitals in hookName', () => { + addFilter( 'hookName', 'action', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'Can add filters with periods in hookName', () => { + addFilter( 'hook.name', 'action', () => null ); + expect( console ).not.toHaveErrored(); +} ); + +test( 'cannot add filters with namespace containing backslash', () => { + addFilter( 'hook_name', 'i\n\v\a\l\i\d\n\a\m\e', () => null ); + expect( console ).toHaveErroredWith( + 'The namespace can only contain numbers, letters, dashes, periods, underscores and slashes.' + ); +} ); + +test( 'cannot add filters named with __ prefix', () => { + addFilter( '__test', 'my_callback', () => null ); + expect( console ).toHaveErroredWith( + 'The hook name cannot begin with `__`.' + ); +} ); + +test( 'cannot add filters with non-function callbacks', () => { + addFilter( 'test', 'my_callback', '42' ); + expect( console ).toHaveErroredWith( + 'The hook callback must be a function.' + ); +} ); + +test( 'cannot add filters with non-numeric priorities', () => { + addFilter( 'test', 'my_callback', () => null, '42' ); + expect( console ).toHaveErroredWith( + 'If specified, the hook priority must be a number.' + ); +} ); + +test( 'add 3 filters with different priorities and run them', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 8 ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testbca' ); +} ); + +test( 'filters with the same and different priorities', () => { + const callbacks = {}; + + [ 1, 2, 3, 4 ].forEach( ( priority ) => { + [ 'a', 'b', 'c', 'd' ].forEach( ( string ) => { + callbacks[ 'fn_' + priority + string ] = ( value ) => { + return value.concat( priority + string ); + }; + } ); + } ); + + addFilter( 'test_order', 'my_callback_fn_3a', callbacks.fn_3a, 3 ); + addFilter( 'test_order', 'my_callback_fn_3b', callbacks.fn_3b, 3 ); + addFilter( 'test_order', 'my_callback_fn_3c', callbacks.fn_3c, 3 ); + addFilter( 'test_order', 'my_callback_fn_2a', callbacks.fn_2a, 2 ); + addFilter( 'test_order', 'my_callback_fn_2b', callbacks.fn_2b, 2 ); + addFilter( 'test_order', 'my_callback_fn_2c', callbacks.fn_2c, 2 ); + + expect( applyFilters( 'test_order', [] ) ).toEqual( + [ '2a', '2b', '2c', '3a', '3b', '3c' ] + ); + + removeFilter( 'test_order', 'my_callback_fn_2b', callbacks.fn_2b ); + removeFilter( 'test_order', 'my_callback_fn_3a', callbacks.fn_3a ); + + expect( applyFilters( 'test_order', [] ) ).toEqual( + [ '2a', '2c', '3b', '3c' ] + ); + + addFilter( 'test_order', 'my_callback_fn_4a', callbacks.fn_4a, 4 ); + addFilter( 'test_order', 'my_callback_fn_4b', callbacks.fn_4b, 4 ); + addFilter( 'test_order', 'my_callback_fn_1a', callbacks.fn_1a, 1 ); + addFilter( 'test_order', 'my_callback_fn_4c', callbacks.fn_4c, 4 ); + addFilter( 'test_order', 'my_callback_fn_1b', callbacks.fn_1b, 1 ); + addFilter( 'test_order', 'my_callback_fn_3d', callbacks.fn_3d, 3 ); + addFilter( 'test_order', 'my_callback_fn_4d', callbacks.fn_4d, 4 ); + addFilter( 'test_order', 'my_callback_fn_1c', callbacks.fn_1c, 1 ); + addFilter( 'test_order', 'my_callback_fn_2d', callbacks.fn_2d, 2 ); + addFilter( 'test_order', 'my_callback_fn_1d', callbacks.fn_1d, 1 ); + + expect( applyFilters( 'test_order', [] ) ).toEqual( [ + // all except 2b and 3a, which we removed earlier + '1a', '1b', '1c', '1d', + '2a', '2c', '2d', + '3b', '3c', '3d', + '4a', '4b', '4c', '4d', + ] ); +} ); + +test( 'add and remove an action', () => { + addAction( 'test.action', 'my_callback', actionA ); + expect( removeAllActions( 'test.action' ) ).toBe( 1 ); + expect( doAction( 'test.action' ) ).toBe( undefined ); + expect( window.actionValue ).toBe( '' ); +} ); + +test( 'add an action and run it', () => { + addAction( 'test.action', 'my_callback', actionA ); + doAction( 'test.action' ); + expect( window.actionValue ).toBe( 'a' ); +} ); + +test( 'add 2 actions in a row and then run them', () => { + addAction( 'test.action', 'my_callback', actionA ); + addAction( 'test.action', 'my_callback', actionB ); + doAction( 'test.action' ); + expect( window.actionValue ).toBe( 'ab' ); +} ); + +test( 'add 3 actions with different priorities and run them', () => { + addAction( 'test.action', 'my_callback', actionA ); + addAction( 'test.action', 'my_callback', actionB, 2 ); + addAction( 'test.action', 'my_callback', actionC, 8 ); + doAction( 'test.action' ); + expect( window.actionValue ).toBe( 'bca' ); +} ); + +test( 'pass in two arguments to an action', () => { + const arg1 = { a: 10 }; + const arg2 = { b: 20 }; + + addAction( 'test.action', 'my_callback', ( a, b ) => { + expect( a ).toBe( arg1 ); + expect( b ).toBe( arg2 ); + } ); + doAction( 'test.action', arg1, arg2 ); +} ); + +test( 'fire action multiple times', () => { + expect.assertions( 2 ); + + function func() { + expect( true ).toBe( true ); + } + + addAction( 'test.action', 'my_callback', func ); + doAction( 'test.action' ); + doAction( 'test.action' ); +} ); + +test( 'add a filter before the one currently executing', () => { + addFilter( 'test.filter', 'my_callback', ( outerValue ) => { + addFilter( 'test.filter', 'my_callback', ( innerValue ) => innerValue + 'a', 1 ); + return outerValue + 'b'; + }, 2 ); + + expect( applyFilters( 'test.filter', 'test_' ) ).toBe( 'test_b' ); +} ); + +test( 'add a filter after the one currently executing', () => { + addFilter( 'test.filter', 'my_callback', ( outerValue ) => { + addFilter( 'test.filter', 'my_callback', ( innerValue ) => innerValue + 'b', 2 ); + return outerValue + 'a'; + }, 1 ); + + expect( applyFilters( 'test.filter', 'test_' ) ).toBe( 'test_ab' ); +} ); + +test( 'add a filter immediately after the one currently executing', () => { + addFilter( 'test.filter', 'my_callback', ( outerValue ) => { + addFilter( 'test.filter', 'my_callback', ( innerValue ) => innerValue + 'b', 1 ); + return outerValue + 'a'; + }, 1 ); + + expect( applyFilters( 'test.filter', 'test_' ) ).toBe( 'test_ab' ); +} ); + +test( 'remove specific action callback', () => { + addAction( 'test.action', 'my_callback_action_a', actionA ); + addAction( 'test.action', 'my_callback_action_b', actionB, 2 ); + addAction( 'test.action', 'my_callback_action_c', actionC, 8 ); + + expect( removeAction( 'test.action', 'my_callback_action_b' ) ).toBe( 1 ); + doAction( 'test.action' ); + expect( window.actionValue ).toBe( 'ca' ); +} ); + +test( 'remove all action callbacks', () => { + addAction( 'test.action', 'my_callback_action_a', actionA ); + addAction( 'test.action', 'my_callback_action_b', actionB, 2 ); + addAction( 'test.action', 'my_callback_action_c', actionC, 8 ); + + expect( removeAllActions( 'test.action' ) ).toBe( 3 ); + doAction( 'test.action' ); + expect( window.actionValue ).toBe( '' ); +} ); + +test( 'remove specific filter callback', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 8 ); + + expect( removeFilter( 'test.filter', 'my_callback_filter_b' ) ).toBe( 1 ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testca' ); +} ); + +test( 'filter removes a callback that has already executed', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 3 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 5 ); + addFilter( 'test.filter', 'my_callback_filter_removes_b', filterRemovesB, 4 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testabc' ); +} ); + +test( 'filter removes a callback that has already executed (same priority)', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_removes_b', filterRemovesB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 4 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testabc' ); +} ); + +test( 'filter removes the current callback', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_c_removes_self', filterCRemovesSelf, 3 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 5 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testabc' ); +} ); + +test( 'filter removes a callback that has not yet executed (last)', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 3 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 5 ); + addFilter( 'test.filter', 'my_callback_filter_removes_c', filterRemovesC, 4 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testab' ); +} ); + +test( 'filter removes a callback that has not yet executed (middle)', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 3 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 4 ); + addFilter( 'test.filter', 'my_callback_filter_removes_b', filterRemovesB, 2 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testac' ); +} ); + +test( 'filter removes a callback that has not yet executed (same priority)', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA, 1 ); + addFilter( 'test.filter', 'my_callback_filter_removes_b', filterRemovesB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 4 ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testac' ); +} ); + +test( 'remove all filter callbacks', () => { + addFilter( 'test.filter', 'my_callback_filter_a', filterA ); + addFilter( 'test.filter', 'my_callback_filter_b', filterB, 2 ); + addFilter( 'test.filter', 'my_callback_filter_c', filterC, 8 ); + + expect( removeAllFilters( 'test.filter' ) ).toBe( 3 ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'test' ); +} ); + +// Test doingAction, didAction, hasAction. +test( 'Test doingAction, didAction and hasAction.', () => { + let actionCalls = 0; + + addAction( 'another.action', 'my_callback', () => {} ); + doAction( 'another.action' ); + + // Verify no action is running yet. + expect( doingAction( 'test.action' ) ).toBe( false ); + + expect( didAction( 'test.action' ) ).toBe( 0 ); + expect( hasAction( 'test.action' ) ).toBe( false ); + + addAction( 'test.action', 'my_callback', () => { + actionCalls++; + expect( currentAction() ).toBe( 'test.action' ); + expect( doingAction() ).toBe( true ); + expect( doingAction( 'test.action' ) ).toBe( true ); + } ); + + // Verify action added, not running yet. + expect( doingAction( 'test.action' ) ).toBe( false ); + expect( didAction( 'test.action' ) ).toBe( 0 ); + expect( hasAction( 'test.action' ) ).toBe( true ); + + doAction( 'test.action' ); + + // Verify action added and running. + expect( actionCalls ).toBe( 1 ); + expect( doingAction( 'test.action' ) ).toBe( false ); + expect( didAction( 'test.action' ) ).toBe( 1 ); + expect( hasAction( 'test.action' ) ).toBe( true ); + expect( doingAction() ).toBe( false ); + expect( doingAction( 'test.action' ) ).toBe( false ); + expect( doingAction( 'notatest.action' ) ).toBe( false ); + expect( currentAction() ).toBe( null ); + + doAction( 'test.action' ); + expect( actionCalls ).toBe( 2 ); + expect( didAction( 'test.action' ) ).toBe( 2 ); + + expect( removeAllActions( 'test.action' ) ).toBe( 1 ); + + // Verify state is reset appropriately. + expect( doingAction( 'test.action' ) ).toBe( false ); + expect( didAction( 'test.action' ) ).toBe( 2 ); + expect( hasAction( 'test.action' ) ).toBe( true ); + + doAction( 'another.action' ); + expect( doingAction( 'test.action' ) ).toBe( false ); + + // Verify an action with no handlers is still counted + expect( didAction( 'unattached.action' ) ).toBe( 0 ); + doAction( 'unattached.action' ); + expect( doingAction( 'unattached.action' ) ).toBe( false ); + expect( didAction( 'unattached.action' ) ).toBe( 1 ); + + doAction( 'unattached.action' ); + expect( doingAction( 'unattached.action' ) ).toBe( false ); + expect( didAction( 'unattached.action' ) ).toBe( 2 ); + + // Verify hasAction returns 0 when no matching action. + expect( hasAction( 'notatest.action' ) ).toBe( false ); +} ); + +test( 'Verify doingFilter, didFilter and hasFilter.', () => { + let filterCalls = 0; + + addFilter( 'runtest.filter', 'my_callback', ( arg ) => { + filterCalls++; + expect( currentFilter() ).toBe( 'runtest.filter' ); + expect( doingFilter() ).toBe( true ); + expect( doingFilter( 'runtest.filter' ) ).toBe( true ); + return arg; + } ); + + // Verify filter added and running. + const test = applyFilters( 'runtest.filter', 'someValue' ); + expect( test ).toBe( 'someValue' ); + expect( filterCalls ).toBe( 1 ); + expect( didFilter( 'runtest.filter' ) ).toBe( 1 ); + expect( hasFilter( 'runtest.filter' ) ).toBe( true ); + expect( hasFilter( 'notatest.filter' ) ).toBe( false ); + expect( doingFilter() ).toBe( false ); + expect( doingFilter( 'runtest.filter' ) ).toBe( false ); + expect( doingFilter( 'notatest.filter' ) ).toBe( false ); + expect( currentFilter() ).toBe( null ); + + expect( removeAllFilters( 'runtest.filter' ) ).toBe( 1 ); + + expect( hasFilter( 'runtest.filter' ) ).toBe( true ); + expect( didFilter( 'runtest.filter' ) ).toBe( 1 ); +} ); + +test( 'recursively calling a filter', () => { + addFilter( 'test.filter', 'my_callback', ( value ) => { + if ( value.length === 7 ) { + return value; + } + return applyFilters( 'test.filter', value + 'X' ); + } ); + + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testXXX' ); +} ); + +test( 'current filter when multiple filters are running', () => { + addFilter( 'test.filter1', 'my_callback', ( value ) => { + return applyFilters( 'test.filter2', value.concat( currentFilter() ) ); + } ); + + addFilter( 'test.filter2', 'my_callback', ( value ) => { + return value.concat( currentFilter() ); + } ); + + expect( currentFilter() ).toBe( null ); + + expect( applyFilters( 'test.filter1', [ 'test' ] ) ).toEqual( + [ 'test', 'test.filter1', 'test.filter2' ] + ); + + expect( currentFilter() ).toBe( null ); +} ); + +test( 'adding and removing filters with recursion', () => { + function removeRecurseAndAdd2( val ) { + expect( removeFilter( 'remove_and_add', 'my_callback_recurse' ) ).toBe( 1 ); + val += '-' + applyFilters( 'remove_and_add', '' ) + '-'; + addFilter( 'remove_and_add', 'my_callback_recurse', removeRecurseAndAdd2, 10 ); + return val + '2'; + } + + addFilter( 'remove_and_add', 'my_callback', ( val ) => val + '1', 11 ); + addFilter( 'remove_and_add', 'my_callback_recurse', removeRecurseAndAdd2, 12 ); + addFilter( 'remove_and_add', 'my_callback', ( val ) => val + '3', 13 ); + addFilter( 'remove_and_add', 'my_callback', ( val ) => val + '4', 14 ); + + expect( applyFilters( 'remove_and_add', '' ) ).toBe( '1-134-234' ); +} ); + +test( 'actions preserve arguments across handlers without return value', () => { + const arg1 = { a: 10 }; + const arg2 = { b: 20 }; + + addAction( 'test.action', 'my_callback1', ( a, b ) => { + expect( a ).toBe( arg1 ); + expect( b ).toBe( arg2 ); + } ); + + addAction( 'test.action', 'my_callback2', ( a, b ) => { + expect( a ).toBe( arg1 ); + expect( b ).toBe( arg2 ); + } ); + + doAction( 'test.action', arg1, arg2 ); +} ); + +test( 'filters pass first argument across handlers', () => { + addFilter( 'test.filter', 'my_callback1', ( count ) => count + 1 ); + addFilter( 'test.filter', 'my_callback2', ( count ) => count + 1 ); + + const result = applyFilters( 'test.filter', 0 ); + + expect( result ).toBe( 2 ); +} ); + +// Test adding via composition. +test( 'adding hooks via composition', () => { + const testObject = {}; + testObject.hooks = createHooks(); + + expect( typeof testObject.hooks.applyFilters ).toBe( 'function' ); +} ); + +// Test adding as a mixin. +test( 'adding hooks as a mixin', () => { + const testObject = {}; + Object.assign( testObject, createHooks() ); + + expect( typeof testObject.applyFilters ).toBe( 'function' ); +} ); + +// Test context. +test( 'Test `this` context via composition', () => { + const testObject = { test: 'test this' }; + + testObject.hooks = createHooks(); + + const theCallback = function() { + expect( this.test ).toBe( 'test this' ); + }; + addAction( 'test.action', 'my_callback', theCallback.bind( testObject ) ); + doAction( 'test.action' ); + + const testObject2 = {}; + Object.assign( testObject2, createHooks() ); +} ); + +const setupActionListener = ( hookName, callback ) => + addAction( hookName, 'my_callback', callback ); + +test( 'adding an action triggers a hookAdded action passing all callback details', () => { + const hookAddedSpy = jest.fn(); + + setupActionListener( 'hookAdded', hookAddedSpy ); + + addAction( 'testAction', 'my_callback2', actionA, 9 ); + expect( hookAddedSpy ).toHaveBeenCalledTimes( 1 ); + expect( hookAddedSpy ).toHaveBeenCalledWith( + 'testAction', + 'my_callback2', + actionA, + 9 + ); +} ); + +test( 'adding a filter triggers a hookAdded action passing all callback details', () => { + const hookAddedSpy = jest.fn(); + + setupActionListener( 'hookAdded', hookAddedSpy ); + + addFilter( 'testFilter', 'my_callback3', filterA, 8 ); + expect( hookAddedSpy ).toHaveBeenCalledTimes( 1 ); + expect( hookAddedSpy ).toHaveBeenCalledWith( + 'testFilter', + 'my_callback3', + filterA, + 8 + ); +} ); + +test( 'removing an action triggers a hookRemoved action passing all callback details', () => { + const hookRemovedSpy = jest.fn(); + + setupActionListener( 'hookRemoved', hookRemovedSpy ); + + addAction( 'testAction', 'my_callback2', actionA, 9 ); + removeAction( 'testAction', 'my_callback2' ); + + expect( hookRemovedSpy ).toHaveBeenCalledTimes( 1 ); + expect( hookRemovedSpy ).toHaveBeenCalledWith( + 'testAction', + 'my_callback2' + ); +} ); + +test( 'removing a filter triggers a hookRemoved action passing all callback details', () => { + const hookRemovedSpy = jest.fn(); + + setupActionListener( 'hookRemoved', hookRemovedSpy ); + + addFilter( 'testFilter', 'my_callback3', filterA, 8 ); + removeFilter( 'testFilter', 'my_callback3' ); + + expect( hookRemovedSpy ).toHaveBeenCalledTimes( 1 ); + expect( hookRemovedSpy ).toHaveBeenCalledWith( + 'testFilter', + 'my_callback3' + ); +} ); + +test( 'add an all filter and run it any hook to trigger it', () => { + addFilter( 'all', 'my_callback', filterA ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testa' ); + expect( applyFilters( 'test.filter-anything', 'test' ) ).toBe( 'testa' ); +} ); + +test( 'add an all action and run it any hook to trigger it', () => { + addAction( 'all', 'my_callback', actionA ); + addAction( 'test.action', 'my_callback', actionA );// Doesn't get triggered. + doAction( 'test.action-anything' ); + expect( window.actionValue ).toBe( 'a' ); +} ); + +test( 'add multiple all filters and run it any hook to trigger them', () => { + addFilter( 'all', 'my_callback', filterA ); + addFilter( 'all', 'my_callback', filterB ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testab' ); + expect( applyFilters( 'test.filter-anything', 'test' ) ).toBe( 'testab' ); +} ); + +test( 'add multiple all actions and run it any hook to trigger them', () => { + addAction( 'all', 'my_callback', actionA ); + addAction( 'all', 'my_callback', actionB ); + addAction( 'test.action', 'my_callback', actionA ); // Doesn't get triggered. + doAction( 'test.action-anything' ); + expect( window.actionValue ).toBe( 'ab' ); +} ); + +test( 'add multiple all filters and run it any hook to trigger them by priority', () => { + addFilter( 'all', 'my_callback', filterA, 11 ); + addFilter( 'all', 'my_callback', filterB, 10 ); + expect( applyFilters( 'test.filter', 'test' ) ).toBe( 'testba' ); + expect( applyFilters( 'test.filter-anything', 'test' ) ).toBe( 'testba' ); +} ); + +test( 'add multiple all actions and run it any hook to trigger them by priority', () => { + addAction( 'all', 'my_callback', actionA, 11 ); + addAction( 'all', 'my_callback', actionB, 10 ); + addAction( 'test.action', 'my_callback', actionA ); // Doesn't get triggered. + doAction( 'test.action-anything' ); + expect( window.actionValue ).toBe( 'ba' ); +} ); + +test( 'checking hasAction with named callbacks and removing', () => { + addAction( 'test.action', 'my_callback', () => {} ); + expect( hasAction( 'test.action', 'not_my_callback' ) ).toBe( false ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( true ); + removeAction( 'test.action', 'my_callback' ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( false ); +} ); + +test( 'checking hasAction with named callbacks and removeAllActions', () => { + addAction( 'test.action', 'my_callback', () => {} ); + addAction( 'test.action', 'my_second_callback', () => {} ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( true ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( true ); + removeAllActions( 'test.action' ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( false ); + expect( hasAction( 'test.action', 'my_callback' ) ).toBe( false ); +} ); + +test( 'checking hasFilter with named callbacks and removing', () => { + addFilter( 'test.filter', 'my_callback', () => {} ); + expect( hasFilter( 'test.filter', 'not_my_callback' ) ).toBe( false ); + expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( true ); + removeFilter( 'test.filter', 'my_callback' ); + expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false ); +} ); + +test( 'checking hasFilter with named callbacks and removeAllActions', () => { + addFilter( 'test.filter', 'my_callback', () => {} ); + addFilter( 'test.filter', 'my_second_callback', () => {} ); + expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( true ); + expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( true ); + removeAllFilters( 'test.filter' ); + expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false ); + expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false ); +} ); diff --git a/gb-src/packages/hooks/src/validateHookName.js b/gb-src/packages/hooks/src/validateHookName.js new file mode 100644 index 0000000000000..2834dafc97401 --- /dev/null +++ b/gb-src/packages/hooks/src/validateHookName.js @@ -0,0 +1,32 @@ +/** + * Validate a hookName string. + * + * @param {string} hookName The hook name to validate. Should be a non empty string containing + * only numbers, letters, dashes, periods and underscores. Also, + * the hook name cannot begin with `__`. + * + * @return {boolean} Whether the hook name is valid. + */ +function validateHookName( hookName ) { + if ( 'string' !== typeof hookName || '' === hookName ) { + // eslint-disable-next-line no-console + console.error( 'The hook name must be a non-empty string.' ); + return false; + } + + if ( /^__/.test( hookName ) ) { + // eslint-disable-next-line no-console + console.error( 'The hook name cannot begin with `__`.' ); + return false; + } + + if ( ! /^[a-zA-Z][a-zA-Z0-9_.-]*$/.test( hookName ) ) { + // eslint-disable-next-line no-console + console.error( 'The hook name can only contain numbers, letters, dashes, periods and underscores.' ); + return false; + } + + return true; +} + +export default validateHookName; diff --git a/gb-src/packages/hooks/src/validateNamespace.js b/gb-src/packages/hooks/src/validateNamespace.js new file mode 100644 index 0000000000000..da3d2d95fe732 --- /dev/null +++ b/gb-src/packages/hooks/src/validateNamespace.js @@ -0,0 +1,25 @@ +/** + * Validate a namespace string. + * + * @param {string} namespace The namespace to validate - should take the form + * `vendor/plugin/function`. + * + * @return {boolean} Whether the namespace is valid. + */ +function validateNamespace( namespace ) { + if ( 'string' !== typeof namespace || '' === namespace ) { + // eslint-disable-next-line no-console + console.error( 'The namespace must be a non-empty string.' ); + return false; + } + + if ( ! /^[a-zA-Z][a-zA-Z0-9_.\-\/]*$/.test( namespace ) ) { + // eslint-disable-next-line no-console + console.error( 'The namespace can only contain numbers, letters, dashes, periods, underscores and slashes.' ); + return false; + } + + return true; +} + +export default validateNamespace; diff --git a/gb-src/packages/html-entities/.npmrc b/gb-src/packages/html-entities/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/html-entities/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/html-entities/CHANGELOG.md b/gb-src/packages/html-entities/CHANGELOG.md new file mode 100644 index 0000000000000..f9355f9f96865 --- /dev/null +++ b/gb-src/packages/html-entities/CHANGELOG.md @@ -0,0 +1,9 @@ +## 2.0.2 (2018-12-12) + +## 2.0.1 (2018-11-21) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/html-entities/README.md b/gb-src/packages/html-entities/README.md new file mode 100644 index 0000000000000..562c835faf53b --- /dev/null +++ b/gb-src/packages/html-entities/README.md @@ -0,0 +1,41 @@ +# HTML Entities + +HTML entity utilities for WordPress. + +## Installation + +Install the module + +```bash +npm install @wordpress/html-entities --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + + + +# **decodeEntities** + +Decodes the HTML entities from a given string. + +_Usage_ + +```js +const result = decodeEntities( 'á' ); +console.log( result ); // result will be "á" +``` + +_Parameters_ + +- _html_ `string`: String that contain HTML entities. + +_Returns_ + +- `string`: The decoded string. + + + + +

Code is Poetry.

diff --git a/gb-src/packages/html-entities/package.json b/gb-src/packages/html-entities/package.json new file mode 100644 index 0000000000000..b0ba97b8cdec8 --- /dev/null +++ b/gb-src/packages/html-entities/package.json @@ -0,0 +1,30 @@ +{ + "name": "@wordpress/html-entities", + "version": "2.5.0", + "description": "HTML entity utilities for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "html", + "entities" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/html-entities/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/html-entities" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/html-entities/src/index.js b/gb-src/packages/html-entities/src/index.js new file mode 100644 index 0000000000000..15c04fe51268a --- /dev/null +++ b/gb-src/packages/html-entities/src/index.js @@ -0,0 +1,35 @@ +let _decodeTextArea; + +/** + * Decodes the HTML entities from a given string. + * + * @param {string} html String that contain HTML entities. + * + * @example + * ```js + * const result = decodeEntities( 'á' ); + * console.log( result ); // result will be "á" + * ``` + * + * @return {string} The decoded string. + */ +export function decodeEntities( html ) { + // not a string, or no entities to decode + if ( 'string' !== typeof html || -1 === html.indexOf( '&' ) ) { + return html; + } + + // create a textarea for decoding entities, that we can reuse + if ( undefined === _decodeTextArea ) { + if ( document.implementation && document.implementation.createHTMLDocument ) { + _decodeTextArea = document.implementation.createHTMLDocument( '' ).createElement( 'textarea' ); + } else { + _decodeTextArea = document.createElement( 'textarea' ); + } + } + + _decodeTextArea.innerHTML = html; + const decoded = _decodeTextArea.textContent; + _decodeTextArea.innerHTML = ''; + return decoded; +} diff --git a/gb-src/packages/html-entities/src/test/entities.js b/gb-src/packages/html-entities/src/test/entities.js new file mode 100644 index 0000000000000..37cabd1e059a2 --- /dev/null +++ b/gb-src/packages/html-entities/src/test/entities.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { decodeEntities } from '../'; + +describe( 'decodeEntities', () => { + it( 'should not change html with no entities', () => { + const html = '

A noble tag embiggens the smallest text.

'; + const expected = '

A noble tag embiggens the smallest text.

'; + expect( decodeEntities( html ) ).toEqual( expected ); + } ); + it( 'should decode entities', () => { + const html = '<h1>This post’s title.</h1>'; + const expected = '

This post’s title.

'; + expect( decodeEntities( html ) ).toEqual( expected ); + } ); + it( 'should not double decode entities', () => { + const html = 'This post&rsquo;s title.'; + const expected = 'This post’s title.'; + expect( decodeEntities( html ) ).toEqual( expected ); + } ); +} ); diff --git a/gb-src/packages/i18n/.npmrc b/gb-src/packages/i18n/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/i18n/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/i18n/CHANGELOG.md b/gb-src/packages/i18n/CHANGELOG.md new file mode 100644 index 0000000000000..8eb9757eb2088 --- /dev/null +++ b/gb-src/packages/i18n/CHANGELOG.md @@ -0,0 +1,38 @@ +## 3.1.0 (2018-11-15) + +### Enhancements + +- The module has been internally refactored to use [Tannin](https://github.com/aduth/tannin) in place of [Jed](https://github.com/messageformat/Jed/). This has no impact on the public interface of the module, but should come with considerable benefit to performance, memory usage, and bundle size. + +## 3.0.0 (2018-09-30) + +### Breaking Changes + +- `getI18n` has been removed. Use `__`, `_x`, `_n`, or `_nx` instead. +- `dcnpgettext` has been removed. Use `__`, `_x`, `_n`, or `_nx` instead. + +### Bug Fixes + +- The initialization of the internal Jed instance now correctly assigns its default data. + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +## 1.2.0 (2018-07-12) + +### New Feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.1.1 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) diff --git a/gb-src/packages/i18n/README.md b/gb-src/packages/i18n/README.md new file mode 100644 index 0000000000000..519a2ad6006c1 --- /dev/null +++ b/gb-src/packages/i18n/README.md @@ -0,0 +1,141 @@ +# Internationalization (i18n) + +Internationalization utilities for client-side localization. See [How to Internationalize Your Plugin](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) for server-side documentation. + +## Installation + +Install the module: + +```bash +npm install @wordpress/i18n --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```js +import { sprintf, _n } from '@wordpress/i18n'; + +sprintf( _n( '%d hat', '%d hats', 4, 'text-domain' ), 4 ); +// 4 hats +``` + +For a complete example, see the [Internationalization section of the Block Editor Handbook](https://developer.wordpress.org/block-editor/developers/internationalization/). + +## API + + + +# **setLocaleData** + +Merges locale data into the Tannin instance by domain. Accepts data in a +Jed-formatted JSON object shape. + +_Related_ + +- + +_Parameters_ + +- _data_ `?Object`: Locale data configuration. +- _domain_ `?string`: Domain for which configuration applies. + +# **sprintf** + +Returns a formatted string. If an error occurs in applying the format, the +original format string is returned. + +_Related_ + +- + +_Parameters_ + +- _format_ `string`: The format of the string to generate. +- _args_ `...string`: Arguments to apply to the format. + +_Returns_ + +- `string`: The formatted string. + +# **\_n** + +Translates and retrieves the singular or plural form based on the supplied +number. + +_Related_ + +- + +_Parameters_ + +- _single_ `string`: The text to be used if the number is singular. +- _plural_ `string`: The text to be used if the number is plural. +- _number_ `number`: The number to compare against to use either the singular or plural form. +- _domain_ `?string`: Domain to retrieve the translated text. + +_Returns_ + +- `string`: The translated singular or plural form. + +# **\_nx** + +Translates and retrieves the singular or plural form based on the supplied +number, with gettext context. + +_Related_ + +- + +_Parameters_ + +- _single_ `string`: The text to be used if the number is singular. +- _plural_ `string`: The text to be used if the number is plural. +- _number_ `number`: The number to compare against to use either the singular or plural form. +- _context_ `string`: Context information for the translators. +- _domain_ `?string`: Domain to retrieve the translated text. + +_Returns_ + +- `string`: The translated singular or plural form. + +# **\_x** + +Retrieve translated string with gettext context. + +_Related_ + +- + +_Parameters_ + +- _text_ `string`: Text to translate. +- _context_ `string`: Context information for the translators. +- _domain_ `?string`: Domain to retrieve the translated text. + +_Returns_ + +- `string`: Translated context string without pipe. + +# **\_\_** + +Retrieve the translation of text. + +_Related_ + +- + +_Parameters_ + +- _text_ `string`: Text to translate. +- _domain_ `?string`: Domain to retrieve the translated text. + +_Returns_ + +- `string`: Translated text. + + + + +

Code is Poetry.

diff --git a/gb-src/packages/i18n/benchmark/index.js b/gb-src/packages/i18n/benchmark/index.js new file mode 100644 index 0000000000000..54d9bddca8b6e --- /dev/null +++ b/gb-src/packages/i18n/benchmark/index.js @@ -0,0 +1,12 @@ +const Benchmark = require( 'benchmark' ); +const { __ } = require( '../' ); + +const suite = new Benchmark.Suite; + +suite + .add( '__', () => { + __( 'Translate' ); + } ) + // eslint-disable-next-line no-console + .on( 'cycle', ( event ) => console.log( event.target.toString() ) ) + .run( { async: true } ); diff --git a/gb-src/packages/i18n/package.json b/gb-src/packages/i18n/package.json new file mode 100644 index 0000000000000..9d0982f761c90 --- /dev/null +++ b/gb-src/packages/i18n/package.json @@ -0,0 +1,37 @@ +{ + "name": "@wordpress/i18n", + "version": "3.6.1", + "description": "WordPress internationalization (i18n) library.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "i18n" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/i18n/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/i18n" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "bin": { + "pot-to-php": "./tools/pot-to-php.js" + }, + "dependencies": { + "@babel/runtime": "^7.4.4", + "gettext-parser": "^1.3.1", + "lodash": "^4.17.15", + "memize": "^1.0.5", + "sprintf-js": "^1.1.1", + "tannin": "^1.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/i18n/src/index.js b/gb-src/packages/i18n/src/index.js new file mode 100644 index 0000000000000..dc17951731201 --- /dev/null +++ b/gb-src/packages/i18n/src/index.js @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +import Tannin from 'tannin'; +import memoize from 'memize'; +import sprintfjs from 'sprintf-js'; + +/** + * Default locale data to use for Tannin domain when not otherwise provided. + * Assumes an English plural forms expression. + * + * @type {Object} + */ +const DEFAULT_LOCALE_DATA = { + '': { + plural_forms: ( n ) => n === 1 ? 0 : 1, + }, +}; + +/** + * Log to console, once per message; or more precisely, per referentially equal + * argument set. Because Jed throws errors, we log these to the console instead + * to avoid crashing the application. + * + * @param {...*} args Arguments to pass to `console.error` + */ +const logErrorOnce = memoize( console.error ); // eslint-disable-line no-console + +/** + * The underlying instance of Tannin to which exported functions interface. + * + * @type {Tannin} + */ +const i18n = new Tannin( {} ); + +/** + * Merges locale data into the Tannin instance by domain. Accepts data in a + * Jed-formatted JSON object shape. + * + * @see http://messageformat.github.io/Jed/ + * + * @param {?Object} data Locale data configuration. + * @param {?string} domain Domain for which configuration applies. + */ +export function setLocaleData( data, domain = 'default' ) { + i18n.data[ domain ] = { + ...DEFAULT_LOCALE_DATA, + ...i18n.data[ domain ], + ...data, + }; + + // Populate default domain configuration (supported locale date which omits + // a plural forms expression). + i18n.data[ domain ][ '' ] = { + ...DEFAULT_LOCALE_DATA[ '' ], + ...i18n.data[ domain ][ '' ], + }; +} + +/** + * Wrapper for Tannin's `dcnpgettext`. Populates default locale data if not + * otherwise previously assigned. + * + * @param {?string} domain Domain to retrieve the translated text. + * @param {?string} context Context information for the translators. + * @param {string} single Text to translate if non-plural. Used as fallback + * return value on a caught error. + * @param {?string} plural The text to be used if the number is plural. + * @param {?number} number The number to compare against to use either the + * singular or plural form. + * + * @return {string} The translated string. + */ +function dcnpgettext( domain = 'default', context, single, plural, number ) { + if ( ! i18n.data[ domain ] ) { + setLocaleData( undefined, domain ); + } + + return i18n.dcnpgettext( domain, context, single, plural, number ); +} + +/** + * Retrieve the translation of text. + * + * @see https://developer.wordpress.org/reference/functions/__/ + * + * @param {string} text Text to translate. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} Translated text. + */ +export function __( text, domain ) { + return dcnpgettext( domain, undefined, text ); +} + +/** + * Retrieve translated string with gettext context. + * + * @see https://developer.wordpress.org/reference/functions/_x/ + * + * @param {string} text Text to translate. + * @param {string} context Context information for the translators. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} Translated context string without pipe. + */ +export function _x( text, context, domain ) { + return dcnpgettext( domain, context, text ); +} + +/** + * Translates and retrieves the singular or plural form based on the supplied + * number. + * + * @see https://developer.wordpress.org/reference/functions/_n/ + * + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {number} number The number to compare against to use either the + * singular or plural form. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} The translated singular or plural form. + */ +export function _n( single, plural, number, domain ) { + return dcnpgettext( domain, undefined, single, plural, number ); +} + +/** + * Translates and retrieves the singular or plural form based on the supplied + * number, with gettext context. + * + * @see https://developer.wordpress.org/reference/functions/_nx/ + * + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {number} number The number to compare against to use either the + * singular or plural form. + * @param {string} context Context information for the translators. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} The translated singular or plural form. + */ +export function _nx( single, plural, number, context, domain ) { + return dcnpgettext( domain, context, single, plural, number ); +} + +/** + * Returns a formatted string. If an error occurs in applying the format, the + * original format string is returned. + * + * @param {string} format The format of the string to generate. + * @param {...string} args Arguments to apply to the format. + * + * @see http://www.diveintojavascript.com/projects/javascript-sprintf + * + * @return {string} The formatted string. + */ +export function sprintf( format, ...args ) { + try { + return sprintfjs.sprintf( format, ...args ); + } catch ( error ) { + logErrorOnce( 'sprintf error: \n\n' + error.toString() ); + + return format; + } +} diff --git a/gb-src/packages/i18n/src/test/index.js b/gb-src/packages/i18n/src/test/index.js new file mode 100644 index 0000000000000..19d398daa47ef --- /dev/null +++ b/gb-src/packages/i18n/src/test/index.js @@ -0,0 +1,128 @@ +/** + * Internal dependencies + */ +import { sprintf, __, _x, _n, _nx, setLocaleData } from '../'; + +// Mock memoization as identity function. Inline since Jest errors on out-of- +// scope references in a mock callback. +jest.mock( 'memize', () => ( fn ) => fn ); + +const localeData = { + '': { + // Domain name + domain: 'test_domain', + lang: 'fr', + // Plural form function for language + plural_forms: 'nplurals=2; plural=(n != 1);', + }, + + hello: [ 'bonjour' ], + + 'verb\u0004feed': [ 'nourrir' ], + + 'hello %s': [ 'bonjour %s' ], + + '%d banana': [ '%d banane', '%d bananes' ], + + 'fruit\u0004%d apple': [ '%d pomme', '%d pommes' ], +}; +const additionalLocaleData = { + cheeseburger: [ 'hamburger au fromage' ], + '%d cat': [ '%d chat', '%d chats' ], +}; + +setLocaleData( localeData, 'test_domain' ); + +describe( 'i18n', () => { + describe( '__', () => { + it( 'use the translation', () => { + expect( __( 'hello', 'test_domain' ) ).toBe( 'bonjour' ); + } ); + } ); + + describe( '_x', () => { + it( 'use the translation with context', () => { + expect( _x( 'feed', 'verb', 'test_domain' ) ).toBe( 'nourrir' ); + } ); + } ); + + describe( '_n', () => { + it( 'use the plural form', () => { + expect( _n( '%d banana', '%d bananas', 3, 'test_domain' ) ).toBe( '%d bananes' ); + } ); + + it( 'use the singular form', () => { + expect( _n( '%d banana', '%d bananas', 1, 'test_domain' ) ).toBe( '%d banane' ); + } ); + } ); + + describe( '_nx', () => { + it( 'use the plural form', () => { + expect( _nx( '%d apple', '%d apples', 3, 'fruit', 'test_domain' ) ).toBe( '%d pommes' ); + } ); + + it( 'use the singular form', () => { + expect( _nx( '%d apple', '%d apples', 1, 'fruit', 'test_domain' ) ).toBe( '%d pomme' ); + } ); + } ); + + describe( 'sprintf()', () => { + it( 'absorbs errors', () => { + // Disable reason: Failing case is the purpose of the test. + // eslint-disable-next-line @wordpress/valid-sprintf + const result = sprintf( 'Hello %(placeholder-not-provided)s' ); + + expect( console ).toHaveErrored(); + expect( result ).toBe( 'Hello %(placeholder-not-provided)s' ); + } ); + + it( 'replaces placeholders', () => { + const result = sprintf( __( 'hello %s', 'test_domain' ), 'Riad' ); + + expect( result ).toBe( 'bonjour Riad' ); + } ); + } ); + + describe( 'setLocaleData', () => { + beforeAll( () => { + setLocaleData( additionalLocaleData, 'test_domain' ); + } ); + + it( 'supports omitted plural forms expression', () => { + setLocaleData( { + '': { + domain: 'test_domain2', + lang: 'fr', + }, + + '%d banana': [ '%d banane', '%d bananes' ], + }, 'test_domain2' ); + + expect( _n( '%d banana', '%d bananes', 2, 'test_domain2' ) ).toBe( '%d bananes' ); + } ); + + describe( '__', () => { + it( 'existing translation still available', () => { + expect( __( 'hello', 'test_domain' ) ).toBe( 'bonjour' ); + } ); + + it( 'new translation available.', () => { + expect( __( 'cheeseburger', 'test_domain' ) ).toBe( 'hamburger au fromage' ); + } ); + } ); + + describe( '_n', () => { + it( 'existing plural form still works', () => { + expect( _n( '%d banana', '%d bananas', 3, 'test_domain' ) ).toBe( '%d bananes' ); + } ); + + it( 'new singular form was added', () => { + expect( _n( '%d cat', '%d cats', 1, 'test_domain' ) ).toBe( '%d chat' ); + } ); + + it( 'new plural form was added', () => { + expect( _n( '%d cat', '%d cats', 3, 'test_domain' ) ).toBe( '%d chats' ); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/i18n/tools/pot-to-php.js b/gb-src/packages/i18n/tools/pot-to-php.js new file mode 100755 index 0000000000000..29aafe3abcf59 --- /dev/null +++ b/gb-src/packages/i18n/tools/pot-to-php.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +const gettextParser = require( 'gettext-parser' ); +const { isEmpty } = require( 'lodash' ); +const fs = require( 'fs' ); + +const TAB = '\t'; +const NEWLINE = '\n'; + +const fileHeader = [ + ' convertTranslationToPHP( translation, options.textdomain, context ) ) + .filter( ( php ) => php !== '' ); + + output = [ ...output, ...newOutput ]; + } + + const fileOutput = fileHeader + output.join( ',' + NEWLINE + NEWLINE ) + fileFooter; + + fs.writeFileSync( phpFile, fileOutput ); +} + +const args = process.argv.slice( 2 ); + +convertPOTToPHP( + args[ 0 ], + args[ 1 ], + { + textdomain: args[ 2 ], + } +); diff --git a/gb-src/packages/is-shallow-equal/.eslintrc.json b/gb-src/packages/is-shallow-equal/.eslintrc.json new file mode 100644 index 0000000000000..2127e794d08b9 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "root": true, + "extends": [ + "plugin:@wordpress/eslint-plugin/es5" + ], + "env": { + "node": true + }, + "overrides": [ + { + "files": [ "@(test|benchmark)/*.js" ], + "extends": [ + "plugin:@wordpress/eslint-plugin/recommended" + ] + } + ] +} diff --git a/gb-src/packages/is-shallow-equal/.npmrc b/gb-src/packages/is-shallow-equal/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/is-shallow-equal/CHANGELOG.md b/gb-src/packages/is-shallow-equal/CHANGELOG.md new file mode 100644 index 0000000000000..17019daee1ea6 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/CHANGELOG.md @@ -0,0 +1,43 @@ +## 1.5.0 (2019-08-05) + +### Bug Fixes + +- Resolved an issue where an explicit `undefined` value in the first object may wrongly report as being shallow equal when the two objects are otherwise of equal length. ([#16329](https://github.com/WordPress/gutenberg/pull/16329)) + +## 1.2.0 (2019-03-06) + +### New Feature + +- Type-specific variants are now exposed from the module root. In a WordPress context, this has the effect of making them available as `wp.isShallowEqual.isShallowEqualObjects` and `wp.isShallowEqual.isShallowEqualArrays`. + +### Internal + +- Development source code linting extends the `@wordpress/eslint-plugin/es5` ruleset. + +## 1.1.0 (2018-07-12) + +### New Feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.2 (2018-05-08) + +### Bug Fix + +- Fix: Use implicit `index.js` for main entry ([#124](https://github.com/WordPress/packages/pull/124)) + +## 1.0.1 (2018-05-01) + +### Bug Fix + +- Fix: Passing a null-ish value as one of the arguments now correctly falls back to a strict equality comparison. ([#116](https://github.com/WordPress/packages/pull/116)) + +## 1.0.0 (2018-04-25) + +### New Feature + +- Initial release diff --git a/gb-src/packages/is-shallow-equal/README.md b/gb-src/packages/is-shallow-equal/README.md new file mode 100644 index 0000000000000..04421cfa1408f --- /dev/null +++ b/gb-src/packages/is-shallow-equal/README.md @@ -0,0 +1,131 @@ +# Is Shallow Equal + +A function for performing a shallow comparison between two objects or arrays. Two values have shallow equality when all of their members are strictly equal to the corresponding member of the other. + +## Usage + +The default export of `@wordpress/is-shallow-equal` is a function which accepts two objects or arrays: + +```js +import isShallowEqual from '@wordpress/is-shallow-equal'; + +isShallowEqual( { a: 1 }, { a: 1, b: 2 } ); +// ⇒ false + +isShallowEqual( { a: 1 }, { a: 1 } ); +// ⇒ true + +isShallowEqual( [ 1 ], [ 1, 2 ] ); +// ⇒ false + +isShallowEqual( [ 1 ], [ 1 ] ); +// ⇒ true +``` + +You can import a specific implementation if you already know the types of values you are working with: + +```js +import { isShallowEqualArrays } from '@wordpress/is-shallow-equal'; +import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; +``` + +Shallow comparison differs from deep comparison by the fact that it compares members from each as being strictly equal to the other, meaning that arrays and objects will be compared by their _references_, not by their values (see also [_Object Equality in JavaScript_.](http://adripofjavascript.com/blog/drips/object-equality-in-javascript.html)) In situations where nested objects must be compared by value, consider using [Lodash's `isEqual`](https://lodash.com/docs/4.17.11#isEqual) instead. + +```js +import isShallowEqual from '@wordpress/is-shallow-equal'; +import { isEqual } from 'lodash'; // deep comparison + +let object = { a: 1 }; + +isShallowEqual( [ { a: 1 } ], [ { a: 1 } ] ); +// ⇒ false + +isEqual( [ { a: 1 } ], [ { a: 1 } ] ); +// ⇒ true + +isShallowEqual( [ object ], [ object ] ); +// ⇒ true +``` + +## Rationale + +Shallow equality utilities are already a dime a dozen. Since these operations are often at the core of critical hot code paths, the WordPress contributors had specific requirements that were found to only be partially satisfied by existing solutions. + +In particular, it should… + +1. …consider non-primitive yet referentially-equal members values as equal. + - Eliminates [`is-equal-shallow`](https://www.npmjs.com/package/is-equal-shallow) as an option. +2. …offer a single function through which to interface, regardless of value type. + - Eliminates [`shallow-equal`](https://www.npmjs.com/package/shallow-equal) as an option. +3. …be barebones; only providing the basic functionality of shallow equality. + - Eliminates [`shallow-equals`](https://www.npmjs.com/package/shallow-equals) as an option. +4. …anticipate and optimize for referential sameness as equal. + - Eliminates [`is-equal-shallow`](https://www.npmjs.com/package/is-equal-shallow) and [`shallow-equals`](https://www.npmjs.com/package/shallow-equals) as options. +5. …be intended for use in non-Facebook projects. + - Eliminates [`fbjs/lib/shallowEqual`](https://www.npmjs.com/package/fbjs) as an option. +6. …be the most performant implementation. + - See [Benchmarks](#benchmarks). + +## Benchmarks + +The following results were produced under Node v10.15.3 (LTS) on a MacBook Pro (Late 2016) 2.9 GHz Intel Core i7. + +>`@wordpress/is-shallow-equal (type specific) (object, equal) x 4,519,009 ops/sec ±1.09% (90 runs sampled)` +>`@wordpress/is-shallow-equal (type specific) (object, same) x 795,527,700 ops/sec ±0.24% (93 runs sampled)` +>`@wordpress/is-shallow-equal (type specific) (object, unequal) x 4,841,640 ops/sec ±0.94% (93 runs sampled)` +>`@wordpress/is-shallow-equal (type specific) (array, equal) x 106,393,795 ops/sec ±0.16% (94 runs sampled)` +>`@wordpress/is-shallow-equal (type specific) (array, same) x 800,741,511 ops/sec ±0.22% (95 runs sampled)` +>`@wordpress/is-shallow-equal (type specific) (array, unequal) x 49,178,977 ops/sec ±1.99% (82 runs sampled)` +> +>`@wordpress/is-shallow-equal (object, equal) x 4,449,367 ops/sec ±0.31% (91 runs sampled)` +>`@wordpress/is-shallow-equal (object, same) x 796,677,179 ops/sec ±0.23% (94 runs sampled)` +>`@wordpress/is-shallow-equal (object, unequal) x 4,989,529 ops/sec ±0.30% (91 runs sampled)` +>`@wordpress/is-shallow-equal (array, equal) x 44,840,546 ops/sec ±1.18% (89 runs sampled)` +>`@wordpress/is-shallow-equal (array, same) x 794,344,723 ops/sec ±0.24% (91 runs sampled)` +>`@wordpress/is-shallow-equal (array, unequal) x 49,860,115 ops/sec ±1.73% (85 runs sampled)` +> +>`shallowequal (object, equal) x 3,702,126 ops/sec ±0.87% (92 runs sampled)` +>`shallowequal (object, same) x 796,649,597 ops/sec ±0.21% (92 runs sampled)` +>`shallowequal (object, unequal) x 4,027,885 ops/sec ±0.31% (96 runs sampled)` +>`shallowequal (array, equal) x 1,684,977 ops/sec ±0.37% (94 runs sampled)` +>`shallowequal (array, same) x 794,287,091 ops/sec ±0.26% (91 runs sampled)` +>`shallowequal (array, unequal) x 1,738,554 ops/sec ±0.29% (91 runs sampled)` +> +>`shallow-equal (type specific) (object, equal) x 4,669,656 ops/sec ±0.34% (92 runs sampled)` +>`shallow-equal (type specific) (object, same) x 799,610,214 ops/sec ±0.20% (95 runs sampled)` +>`shallow-equal (type specific) (object, unequal) x 4,908,591 ops/sec ±0.49% (93 runs sampled)` +>`shallow-equal (type specific) (array, equal) x 104,711,254 ops/sec ±0.65% (91 runs sampled)` +>`shallow-equal (type specific) (array, same) x 798,454,281 ops/sec ±0.29% (94 runs sampled)` +>`shallow-equal (type specific) (array, unequal) x 48,764,338 ops/sec ±1.48% (84 runs sampled)` +> +>`is-equal-shallow (object, equal) x 5,068,750 ops/sec ±0.28% (92 runs sampled)` +>`is-equal-shallow (object, same) x 17,231,997 ops/sec ±0.42% (92 runs sampled)` +>`is-equal-shallow (object, unequal) x 5,524,878 ops/sec ±0.41% (92 runs sampled)` +>`is-equal-shallow (array, equal) x 1,067,063 ops/sec ±0.40% (92 runs sampled)` +>`is-equal-shallow (array, same) x 1,074,356 ops/sec ±0.20% (94 runs sampled)` +>`is-equal-shallow (array, unequal) x 1,758,859 ops/sec ±0.44% (92 runs sampled)` +> +>`shallow-equals (object, equal) x 8,380,550 ops/sec ±0.31% (90 runs sampled)` +>`shallow-equals (object, same) x 27,583,073 ops/sec ±0.60% (91 runs sampled)` +>`shallow-equals (object, unequal) x 8,954,268 ops/sec ±0.71% (92 runs sampled)` +>`shallow-equals (array, equal) x 104,437,640 ops/sec ±0.22% (96 runs sampled)` +>`shallow-equals (array, same) x 141,850,542 ops/sec ±0.25% (93 runs sampled)` +>`shallow-equals (array, unequal) x 47,964,211 ops/sec ±1.51% (84 runs sampled)` +> +>`fbjs/lib/shallowEqual (object, equal) x 3,366,709 ops/sec ±0.35% (93 runs sampled)` +>`fbjs/lib/shallowEqual (object, same) x 794,825,194 ops/sec ±0.24% (94 runs sampled)` +>`fbjs/lib/shallowEqual (object, unequal) x 3,612,268 ops/sec ±0.37% (94 runs sampled)` +>`fbjs/lib/shallowEqual (array, equal) x 1,613,800 ops/sec ±0.23% (90 runs sampled)` +>`fbjs/lib/shallowEqual (array, same) x 794,861,384 ops/sec ±0.24% (93 runs sampled)` +>`fbjs/lib/shallowEqual (array, unequal) x 1,648,398 ops/sec ±0.77% (92 runs sampled)` + +You can run the benchmarks yourselves by cloning the repository, installing dependencies, and running the `benchmark/index.js` script: + +``` +git clone https://github.com/WordPress/packages.git +cd packages/packages/is-shallow-equal +npm install +node benchmark +``` + +

Code is Poetry.

diff --git a/gb-src/packages/is-shallow-equal/arrays.js b/gb-src/packages/is-shallow-equal/arrays.js new file mode 100644 index 0000000000000..950224a8c8c50 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/arrays.js @@ -0,0 +1,31 @@ +'use strict'; + +/** + * Returns true if the two arrays are shallow equal, or false otherwise. + * + * @param {Array} a First array to compare. + * @param {Array} b Second array to compare. + * + * @return {boolean} Whether the two arrays are shallow equal. + */ +function isShallowEqualArrays( a, b ) { + var i; + + if ( a === b ) { + return true; + } + + if ( a.length !== b.length ) { + return false; + } + + for ( i = 0; i < a.length; i++ ) { + if ( a[ i ] !== b[ i ] ) { + return false; + } + } + + return true; +} + +module.exports = isShallowEqualArrays; diff --git a/gb-src/packages/is-shallow-equal/benchmark/index.js b/gb-src/packages/is-shallow-equal/benchmark/index.js new file mode 100644 index 0000000000000..f2e363724126c --- /dev/null +++ b/gb-src/packages/is-shallow-equal/benchmark/index.js @@ -0,0 +1,53 @@ +'use strict'; + +const Benchmark = require( 'benchmark' ); + +const suite = new Benchmark.Suite; + +const beforeObject = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; +const afterObjectSame = beforeObject; +const afterObjectEqual = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; +const afterObjectUnequal = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 'Unequal', g: 7 }; +const beforeArray = [ 1, 2, 3, 4, 5, 6, 7 ]; +const afterArraySame = beforeArray; +const afterArrayEqual = [ 1, 2, 3, 4, 5, 6, 7 ]; +const afterArrayUnequal = [ 1, 2, 3, 4, 5, 'Unequal', 7 ]; + +[ + [ '@wordpress/is-shallow-equal (type specific)', require( '../objects' ), require( '../arrays' ) ], + [ '@wordpress/is-shallow-equal', require( '../' ) ], + [ 'shallowequal', require( 'shallowequal' ) ], + [ 'shallow-equal (type specific)', require( 'shallow-equal/objects' ), require( 'shallow-equal/arrays' ) ], + [ 'is-equal-shallow', require( 'is-equal-shallow' ) ], + [ 'shallow-equals', require( 'shallow-equals' ) ], + [ 'fbjs/lib/shallowEqual', require( 'fbjs/lib/shallowEqual' ) ], +].forEach( ( [ name, isShallowEqualObjects, isShallowEqualArrays = isShallowEqualObjects ] ) => { + suite.add( name + ' (object, equal)', () => { + isShallowEqualObjects( beforeObject, afterObjectEqual ); + } ); + + suite.add( name + ' (object, same)', () => { + isShallowEqualObjects( beforeObject, afterObjectSame ); + } ); + + suite.add( name + ' (object, unequal)', () => { + isShallowEqualObjects( beforeObject, afterObjectUnequal ); + } ); + + suite.add( name + ' (array, equal)', () => { + isShallowEqualArrays( beforeArray, afterArrayEqual ); + } ); + + suite.add( name + ' (array, same)', () => { + isShallowEqualArrays( beforeArray, afterArraySame ); + } ); + + suite.add( name + ' (array, unequal)', () => { + isShallowEqualArrays( beforeArray, afterArrayUnequal ); + } ); +} ); + +suite + // eslint-disable-next-line no-console + .on( 'cycle', ( event ) => console.log( event.target.toString() ) ) + .run( { async: true } ); diff --git a/gb-src/packages/is-shallow-equal/index.js b/gb-src/packages/is-shallow-equal/index.js new file mode 100644 index 0000000000000..f24dd1ea07a90 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/index.js @@ -0,0 +1,34 @@ +'use strict'; + +/** + * Internal dependencies; + */ +var isShallowEqualObjects = require( './objects' ); +var isShallowEqualArrays = require( './arrays' ); + +var isArray = Array.isArray; + +/** + * Returns true if the two arrays or objects are shallow equal, or false + * otherwise. + * + * @param {(Array|Object)} a First object or array to compare. + * @param {(Array|Object)} b Second object or array to compare. + * + * @return {boolean} Whether the two values are shallow equal. + */ +function isShallowEqual( a, b ) { + if ( a && b ) { + if ( a.constructor === Object && b.constructor === Object ) { + return isShallowEqualObjects( a, b ); + } else if ( isArray( a ) && isArray( b ) ) { + return isShallowEqualArrays( a, b ); + } + } + + return a === b; +} + +module.exports = isShallowEqual; +module.exports.isShallowEqualObjects = isShallowEqualObjects; +module.exports.isShallowEqualArrays = isShallowEqualArrays; diff --git a/gb-src/packages/is-shallow-equal/objects.js b/gb-src/packages/is-shallow-equal/objects.js new file mode 100644 index 0000000000000..ebe1105b19615 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/objects.js @@ -0,0 +1,51 @@ +'use strict'; + +var keys = Object.keys; + +/** + * Returns true if the two objects are shallow equal, or false otherwise. + * + * @param {Object} a First object to compare. + * @param {Object} b Second object to compare. + * + * @return {boolean} Whether the two objects are shallow equal. + */ +function isShallowEqualObjects( a, b ) { + var aKeys, bKeys, i, key, aValue; + + if ( a === b ) { + return true; + } + + aKeys = keys( a ); + bKeys = keys( b ); + + if ( aKeys.length !== bKeys.length ) { + return false; + } + + i = 0; + + while ( i < aKeys.length ) { + key = aKeys[ i ]; + aValue = a[ key ]; + + if ( + // In iterating only the keys of the first object after verifying + // equal lengths, account for the case that an explicit `undefined` + // value in the first is implicitly undefined in the second. + // + // Example: isShallowEqualObjects( { a: undefined }, { b: 5 } ) + ( aValue === undefined && ! b.hasOwnProperty( key ) ) || + aValue !== b[ key ] + ) { + return false; + } + + i++; + } + + return true; +} + +module.exports = isShallowEqualObjects; diff --git a/gb-src/packages/is-shallow-equal/package.json b/gb-src/packages/is-shallow-equal/package.json new file mode 100644 index 0000000000000..a164a7cdbb44f --- /dev/null +++ b/gb-src/packages/is-shallow-equal/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/is-shallow-equal", + "version": "1.6.1", + "description": "Test for shallow equality between two objects or arrays.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "shallow", + "shallow-equal", + "shallowequal" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/is-shallow-equal/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/is-shallow-equal" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "files": [ + "arrays.js", + "index.js", + "objects.js" + ], + "main": "index.js", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/is-shallow-equal/test/index.js b/gb-src/packages/is-shallow-equal/test/index.js new file mode 100644 index 0000000000000..3ec86ed352928 --- /dev/null +++ b/gb-src/packages/is-shallow-equal/test/index.js @@ -0,0 +1,173 @@ +/** + * Internal dependencies + */ +import isShallowEqual from '../'; +import isShallowEqualArrays from '../arrays'; +import isShallowEqualObjects from '../objects'; + +describe( 'isShallowEqual', () => { + it( 'returns false if of different types', () => { + const a = [ 1 ]; + const b = { 0: 1 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a object has more keys than b', () => { + const a = { foo: 1, bar: 2 }; + const b = { foo: 1 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if b object has more keys than a', () => { + const a = { foo: 1 }; + const b = { foo: 1, bar: 2 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a object has different value than b', () => { + const a = { foo: 1, bar: 2 }; + const b = { foo: 1, bar: 1 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a object has different key than b', () => { + const a = { foo: 1, bar: 2 }; + const b = { foo: 1, baz: 2 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a object has undefined key not in b', () => { + const a = { foo: undefined }; + const b = { bar: 2 }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns true if a object has same values as b', () => { + const a = { foo: 1, bar: 2 }; + const b = { foo: 1, bar: 2 }; + + expect( isShallowEqual( a, b ) ).toBe( true ); + expect( isShallowEqual( b, a ) ).toBe( true ); + } ); + + it( 'returns true if a object strictly equals b', () => { + const obj = { foo: 1 }; + + expect( isShallowEqual( obj, obj ) ).toBe( true ); + } ); + + it( 'returns true on objects that are a copy of each other', () => { + const a = { foo: 1 }; + const b = a; + + expect( isShallowEqual( a, b ) ).toBe( true ); + } ); + + it( 'returns true on object deep-but-referentially-equal values', () => { + const obj = {}; + const a = { foo: obj }; + const b = { foo: obj }; + + expect( isShallowEqual( a, b ) ).toBe( true ); + expect( isShallowEqual( b, a ) ).toBe( true ); + } ); + + it( 'returns false on object deep-but-referentially-unequal values', () => { + const a = { foo: {} }; + const b = { foo: {} }; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a array has more keys than b', () => { + const a = [ 1, 2 ]; + const b = [ 1 ]; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if b array has more keys than a', () => { + const a = [ 1 ]; + const b = [ 1, 2 ]; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns false if a array has different value than b', () => { + const a = [ 1, 1 ]; + const b = [ 1, 2 ]; + + expect( isShallowEqual( a, b ) ).toBe( false ); + expect( isShallowEqual( b, a ) ).toBe( false ); + } ); + + it( 'returns true if a array has same values as b', () => { + const a = [ 1, 2 ]; + const b = [ 1, 2 ]; + + expect( isShallowEqual( a, b ) ).toBe( true ); + expect( isShallowEqual( b, a ) ).toBe( true ); + } ); + + it( 'returns true on array deep-but-referentially-equal values', () => { + const obj = {}; + const a = [ obj ]; + const b = [ obj ]; + + expect( isShallowEqual( a, b ) ).toBe( true ); + expect( isShallowEqual( b, a ) ).toBe( true ); + } ); + + it( 'falls back to strict equality when one of arguments is non-object-like', () => { + expect( isShallowEqual( undefined, {} ) ).toBe( false ); + expect( isShallowEqual( undefined, [] ) ).toBe( false ); + expect( isShallowEqual( undefined, undefined ) ).toBe( true ); + expect( isShallowEqual( {}, null ) ).toBe( false ); + expect( isShallowEqual( [], {} ) ).toBe( false ); + expect( isShallowEqual( null, [] ) ).toBe( false ); + expect( isShallowEqual( null, null ) ).toBe( true ); + expect( isShallowEqual( 1, true ) ).toBe( false ); + expect( isShallowEqual( true, true ) ).toBe( true ); + expect( isShallowEqual( null, undefined ) ).toBe( false ); + } ); + + it( 'returns true on arrays that are a copy of each other', () => { + const a = []; + const b = a; + + expect( isShallowEqual( a, b ) ).toBe( true ); + } ); +} ); + +describe( 'isShallowEqualArrays', () => { + it( 'is exported as a clone of the function from index', () => { + const a = [ 1, 2 ]; + const b = [ 1, 2 ]; + + expect( isShallowEqualArrays( a, b ) ).toBe( true ); + } ); +} ); + +describe( 'isShallowEqualObjects', () => { + it( 'is exported as a clone of the function from index', () => { + const a = { foo: 1, bar: 2 }; + const b = { foo: 1, bar: 2 }; + + expect( isShallowEqualObjects( a, b ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/jest-console/.npmrc b/gb-src/packages/jest-console/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/jest-console/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/jest-console/CHANGELOG.md b/gb-src/packages/jest-console/CHANGELOG.md new file mode 100644 index 0000000000000..3533278048ea4 --- /dev/null +++ b/gb-src/packages/jest-console/CHANGELOG.md @@ -0,0 +1,36 @@ +## 3.0.0 (2019-03-06) + +### Breaking Changes + +- Increased the recommended Jest dependency to version 24 ([#13922](https://github.com/WordPress/gutenberg/pull/13922). + +## 2.0.7 (2018-11-20) + +## 2.0.5 (2018-09-30) + +### Bug Fixes + +- Captures and protects against unexpected console logging occurring during lifecycle. + +## 2.0.0 (2018-07-12) + +### Breaking Changes + +- Add new API methods `toHaveInformed`, `toHaveInformedWith`, `toHaveLogged` and `toHaveLoggedWith` ([#137](https://github.com/WordPress/packages/pull/137)). If the code under test calls `console.log` or `console.info` it will fail, unless one of the newly introduced methods is explicitly used to verify it. +- Updated code to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.7 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) + +## 1.0.6 (2018-02-28) + +### Polish + +- Removed `package-lock.json` file, lockfiles for apps, not packages ([#88](https://github.com/WordPress/packages/pull/88)) diff --git a/gb-src/packages/jest-console/README.md b/gb-src/packages/jest-console/README.md new file mode 100644 index 0000000000000..31909d570122a --- /dev/null +++ b/gb-src/packages/jest-console/README.md @@ -0,0 +1,114 @@ +# Jest Console + +Custom [Jest](https://jestjs.io/) matchers for the [Console](https://developer.mozilla.org/en-US/docs/Web/API/Console) +object to test JavaScript code in WordPress. + +This package converts `console.error`, `console.info`, `console.log` and `console.warn` functions into mocks and tracks their calls. +It also enforces usage of one of the related matchers whenever tested code calls one of the mentioned `console` methods. +It means that you need to assert with `.toHaveErrored()` or `.toHaveErroredWith( arg1, arg2, ... )` when `console.error` +gets executed, and use the corresponding methods when `console.info`, `console.log` or `console.warn` are called. +Your test will fail otherwise! This is a conscious design decision which helps to detect deprecation warnings when +upgrading dependent libraries or smaller errors when refactoring code. + +## Installation + +Install the module: + +```bash +npm install @wordpress/jest-console --save-dev +``` + +### Setup + +The simplest setup is to use Jest's `setupFilesAfterEnv` config option: + +```js +"jest": { + "setupFilesAfterEnv": [ + "@wordpress/jest-console" + ] +}, +``` + +### Usage + +### `.toHaveErrored()` + +Use `.toHaveErrored` to ensure that `console.error` function was called. + +For example, let's say you have a `drinkAll( flavor )` function that makes you drink all available beverages. +You might want to check if function calls `console.error` for `'octopus'` instead, because `'octopus'` flavor is really +weird and why would anything be octopus-flavored? You can do that with this test suite: + +```js +describe( 'drinkAll', () => { + test( 'drinks something lemon-flavored', () => { + drinkAll( 'lemon' ); + expect( console ).not.toHaveErrored(); + } ); + + test( 'errors when something is octopus-flavored', () => { + drinkAll( 'octopus' ); + expect( console ).toHaveErrored(); + } ); +} ); +``` + +### `.toHaveErroredWith( arg1, arg2, ... )` + +Use `.toHaveErroredWith` to ensure that `console.error` function was called with +specific arguments. + +For example, let's say you have a `drinkAll( flavor )` function again makes you drink all available beverages. +You might want to check if function calls `console.error` with a specific message for `'octopus'` instead, because +`'octopus'` flavor is really weird and why would anything be octopus-flavored? To make sure this works, you could write: + +```js +describe( 'drinkAll', () => { + test( 'errors with message when something is octopus-flavored', () => { + drinkAll( 'octopus' ); + expect( console ).toHaveErroredWith( 'Should I really drink something that is octopus-flavored?' ); + } ); +} ); +``` + +### `.toHaveInformed()` + +Use `.toHaveInformed` to ensure that `console.info` function was called. + +Almost identical usage as `.toHaveErrored()`. + +### `.toHaveInformedWith( arg1, arg2, ... )` + +Use `.toHaveInformedWith` to ensure that `console.info` function was called with +specific arguments. + +Almost identical usage as `.toHaveErroredWith()`. + +### `.toHaveLogged()` + +Use `.toHaveLogged` to ensure that `console.log` function was called. + +Almost identical usage as `.toHaveErrored()`. + +### `.toHaveLoggedWith( arg1, arg2, ... )` + +Use `.toHaveLoggedWith` to ensure that `console.log` function was called with +specific arguments. + +Almost identical usage as `.toHaveErroredWith()`. + +### `.toHaveWarned()` + +Use `.toHaveWarned` to ensure that `console.warn` function was called. + +Almost identical usage as `.toHaveErrored()`. + +### `.toHaveWarnedWith( arg1, arg2, ... )` + +Use `.toHaveWarneddWith` to ensure that `console.warn` function was called with +specific arguments. + +Almost identical usage as `.toHaveErroredWith()`. + +

Code is Poetry.

diff --git a/gb-src/packages/jest-console/package.json b/gb-src/packages/jest-console/package.json new file mode 100644 index 0000000000000..79b9db84af698 --- /dev/null +++ b/gb-src/packages/jest-console/package.json @@ -0,0 +1,42 @@ +{ + "name": "@wordpress/jest-console", + "version": "3.3.1", + "description": "Custom Jest matchers for the Console object.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "jest", + "matchers", + "console" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/jest-console/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/jest-console" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "build", + "build-module" + ], + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@babel/runtime": "^7.4.4", + "jest-matcher-utils": "^24.7.0", + "lodash": "^4.17.15" + }, + "peerDependencies": { + "jest": ">=24" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/jest-console/src/index.js b/gb-src/packages/jest-console/src/index.js new file mode 100644 index 0000000000000..02212e517586a --- /dev/null +++ b/gb-src/packages/jest-console/src/index.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { forEach } from 'lodash'; + +/** + * Internal dependencies + */ +import './matchers'; +import supportedMatchers from './supported-matchers'; + +/** + * Sets spy on the console object's method to make it possible to fail test when method called without assertion. + * + * @param {string} matcherName Name of Jest matcher. + * @param {string} methodName Name of console method. + */ +const setConsoleMethodSpy = ( matcherName, methodName ) => { + const spy = jest.spyOn( console, methodName ).mockName( `console.${ methodName }` ); + + /** + * Resets the spy to its initial state. + */ + function resetSpy() { + spy.mockReset(); + spy.assertionsNumber = 0; + } + + /** + * Verifies that the spy has only been called if expected. + */ + function assertExpectedCalls() { + if ( spy.assertionsNumber === 0 && spy.mock.calls.length > 0 ) { + expect( console ).not[ matcherName ](); + } + } + + beforeAll( resetSpy ); + + beforeEach( () => { + assertExpectedCalls(); + resetSpy(); + } ); + + afterEach( assertExpectedCalls ); +}; + +forEach( supportedMatchers, setConsoleMethodSpy ); diff --git a/gb-src/packages/jest-console/src/matchers.js b/gb-src/packages/jest-console/src/matchers.js new file mode 100644 index 0000000000000..fa2d6487340b8 --- /dev/null +++ b/gb-src/packages/jest-console/src/matchers.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; +import { isEqual, reduce, some } from 'lodash'; + +/** + * Internal dependencies + */ +import supportedMatchers from './supported-matchers'; + +const createToHaveBeenCalledMatcher = ( matcherName, methodName ) => + ( received ) => { + const spy = received[ methodName ]; + const calls = spy.mock.calls; + const pass = calls.length > 0; + const message = pass ? + () => + matcherHint( `.not${ matcherName }`, spy.getMockName() ) + + '\n\n' + + 'Expected mock function not to be called but it was called with:\n' + + calls.map( printReceived ) : + () => + matcherHint( matcherName, spy.getMockName() ) + + '\n\n' + + 'Expected mock function to be called.'; + + spy.assertionsNumber += 1; + + return { + message, + pass, + }; + }; + +const createToHaveBeenCalledWith = ( matcherName, methodName ) => + ( received, ...expected ) => { + const spy = received[ methodName ]; + const calls = spy.mock.calls; + const pass = some( + calls, + ( objects ) => isEqual( objects, expected ) + ); + const message = pass ? + () => + matcherHint( `.not${ matcherName }`, spy.getMockName() ) + + '\n\n' + + 'Expected mock function not to be called with:\n' + + printExpected( expected ) : + () => + matcherHint( matcherName, spy.getMockName() ) + + '\n\n' + + 'Expected mock function to be called with:\n' + + printExpected( expected ) + '\n' + + 'but it was called with:\n' + + calls.map( printReceived ); + + spy.assertionsNumber += 1; + + return { + message, + pass, + }; + }; + +expect.extend( + reduce( supportedMatchers, ( result, matcherName, methodName ) => { + const matcherNameWith = `${ matcherName }With`; + + return { + ...result, + [ matcherName ]: createToHaveBeenCalledMatcher( `.${ matcherName }`, methodName ), + [ matcherNameWith ]: createToHaveBeenCalledWith( `.${ matcherNameWith }`, methodName ), + }; + }, {} ) +); diff --git a/gb-src/packages/jest-console/src/supported-matchers.js b/gb-src/packages/jest-console/src/supported-matchers.js new file mode 100644 index 0000000000000..636ad897e00ef --- /dev/null +++ b/gb-src/packages/jest-console/src/supported-matchers.js @@ -0,0 +1,8 @@ +const supportedMatchers = { + error: 'toHaveErrored', + info: 'toHaveInformed', + log: 'toHaveLogged', + warn: 'toHaveWarned', +}; + +export default supportedMatchers; diff --git a/gb-src/packages/jest-console/src/test/index.test.js b/gb-src/packages/jest-console/src/test/index.test.js new file mode 100644 index 0000000000000..c626a1c2bc94c --- /dev/null +++ b/gb-src/packages/jest-console/src/test/index.test.js @@ -0,0 +1,80 @@ +/* eslint-disable no-console */ + +/** + * Internal dependencies + */ +import '../matchers'; + +describe( 'jest-console', () => { + describe.each( [ [ 'error', 'toHaveErrored' ], [ 'info', 'toHaveInformed' ], [ 'log', 'toHaveLogged' ], [ 'warn', 'toHaveWarned' ] ] )( + 'console.%s', + ( methodName, matcherName ) => { + const matcherNameWith = `${ matcherName }With`; + const message = `This is ${ methodName }!`; + + test( `${ matcherName } works`, () => { + console[ methodName ]( message ); + + expect( console )[ matcherName ](); + } ); + + test( `${ matcherName } works when not called`, () => { + expect( console ).not[ matcherName ](); + expect( + () => expect( console )[ matcherName ]() + ).toThrow( 'Expected mock function to be called.' ); + } ); + + test( `${ matcherNameWith } works with arguments that match`, () => { + console[ methodName ]( message ); + + expect( console )[ matcherNameWith ]( message ); + } ); + + test( `${ matcherNameWith } works when not called`, () => { + expect( console ).not[ matcherNameWith ]( message ); + expect( + () => expect( console )[ matcherNameWith ]( message ) + ).toThrow( /Expected mock function to be called with:.*but it was called with:/s ); + } ); + + test( `${ matcherNameWith } works with many arguments that do not match`, () => { + console[ methodName ]( 'Unknown message.' ); + console[ methodName ]( message, 'Unknown param.' ); + + expect( console ).not[ matcherNameWith ]( message ); + expect( + () => expect( console )[ matcherNameWith ]( message ) + ).toThrow( /Expected mock function to be called with:.*but it was called with:.*Unknown param./s ); + } ); + + test( 'assertions number gets incremented after every matcher call', () => { + const spy = console[ methodName ]; + + expect( spy.assertionsNumber ).toBe( 0 ); + + console[ methodName ]( message ); + + expect( console )[ matcherName ](); + expect( spy.assertionsNumber ).toBe( 1 ); + + expect( console )[ matcherNameWith ]( message ); + expect( spy.assertionsNumber ).toBe( 2 ); + } ); + + describe( 'lifecycle', () => { + beforeAll( () => { + // This is a difficult one to test, since the matcher's + // own lifecycle is defined to run before ours. Infer + // that we're being watched by testing the console + // method as being a spy. + expect( console[ methodName ].assertionsNumber ).toBeGreaterThanOrEqual( 0 ); + } ); + + it( 'captures logging in lifecycle', () => {} ); + } ); + } + ); +} ); + +/* eslint-enable no-console */ diff --git a/gb-src/packages/jest-preset-default/.npmrc b/gb-src/packages/jest-preset-default/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/jest-preset-default/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/jest-preset-default/CHANGELOG.md b/gb-src/packages/jest-preset-default/CHANGELOG.md new file mode 100644 index 0000000000000..b99f7bbcdbbb6 --- /dev/null +++ b/gb-src/packages/jest-preset-default/CHANGELOG.md @@ -0,0 +1,64 @@ +## Master + +## 5.1.0 (2019-09-03) + +### Bug Fixes + +- Add `wordpress` folder to the list of ignored paths ([#17296](https://github.com/WordPress/gutenberg/pull/17296)). + +## 5.0.0 (2019-08-29) + +### Breaking Changes + +- Files with `.spec.js` suffix are no longer matched as test files by default. + +### New Features + +- Align `testMatch` config option with Jest and allow test files with `.ts` suffix. + +## 4.0.0 (2019-03-06) + +### Breaking Changes + +- The bundled `jest` dependency has been updated from requiring `^23.6.0` to requiring `^24.1.0` (see [Breaking Changes](https://jestjs.io/blog/2019/01/25/jest-24-refreshing-polished-typescript-friendly#breaking-changes), [#13922](https://github.com/WordPress/gutenberg/pull/13922)). +- The bundled `jest-enzyme` dependency has been removed completely ([#13922](https://github.com/WordPress/gutenberg/pull/13922)). + +### Internal + +- The bundled `enzyme` dependency has been updated from requiring `^3.7.0` to requiring `^3.9.0` ([#13922](https://github.com/WordPress/gutenberg/pull/13922)). +- The bundled `enzyme-adapter-react-16` dependency has been updated from requiring `^1.6.0` to requiring `^1.10.0` ([#13922](https://github.com/WordPress/gutenberg/pull/13922)). + +## 3.0.3 (2018-11-20) + +## 3.0.2 (2018-11-09) + +## 3.0.1 (2018-11-09) + +## 3.0.0 (2018-11-03) + +### Breaking Change + +- Remove coverage support. + +## 2.0.0 (2018-07-12) + +### Breaking Change + +- Updated code to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.6 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) + +## 1.0.5 (2018-03-22) + +### Polish + +- Docs: Wrap filename in backticks ([#89](https://github.com/WordPress/packages/pull/89)) +- Add `jest-preset` keyword to `jest-preset-default` package ([#92](https://github.com/WordPress/packages/pull/92)) diff --git a/gb-src/packages/jest-preset-default/README.md b/gb-src/packages/jest-preset-default/README.md new file mode 100644 index 0000000000000..ab5251a109868 --- /dev/null +++ b/gb-src/packages/jest-preset-default/README.md @@ -0,0 +1,37 @@ +# Jest Preset Default + +Default [Jest](https://jestjs.io/) preset for WordPress development. + +## Installation + +Install the module + +```bash +npm install @wordpress/jest-preset-default --save-dev +``` + +## Setup + +### Via `jest.config.json` or `jest` field in `package.json` + +```json +{ + "preset": "@wordpress/jest-preset-default" +} +``` + +### Usage + +#### Brief explanations of options included + +* `moduleNameMapper` - all `css` and `scss` files containing CSS styles will be stubbed out. +* `modulePaths` - the root dir of the project is used as a location to search when resolving modules. +* `setupFiles` - runs code before each test which sets up global variables required in the testing environment. +* `setupFilesAfterEnv` - runs code which adds improved support for `Console` object and `React` components to the testing framework before each test. +* `snapshotSerializers` - makes it possible to use snapshot tests on `Enzyme` wrappers. +* `testMatch`- includes `/test/` subfolder in addition to the glob patterns Jest uses to detect test files. It detects only test files containing `.js` (or `.ts`) suffix. It doesn't match files with `.spec.js` suffix. +* `timers` - use of [fake timers](https://jestjs.io/docs/en/timer-mocks.html) for functions such as `setTimeout` is enabled. +* `transform` - keeps the default [babel-jest](https://github.com/facebook/jest/tree/master/packages/babel-jest) transformer. +* `verbose` - each individual test won't be reported during the run. + +

Code is Poetry.

diff --git a/gb-src/packages/jest-preset-default/index.js b/gb-src/packages/jest-preset-default/index.js new file mode 100644 index 0000000000000..a2a6a4c23cc14 --- /dev/null +++ b/gb-src/packages/jest-preset-default/index.js @@ -0,0 +1 @@ +module.exports = require( './scripts/setup-test-framework' ); diff --git a/gb-src/packages/jest-preset-default/jest-preset.json b/gb-src/packages/jest-preset-default/jest-preset.json new file mode 100644 index 0000000000000..54b62de584fcc --- /dev/null +++ b/gb-src/packages/jest-preset-default/jest-preset.json @@ -0,0 +1,31 @@ +{ + "moduleNameMapper": { + "\\.(scss|css)$": "/node_modules/@wordpress/jest-preset-default/scripts/style-mock.js" + }, + "modulePaths": [ + "" + ], + "setupFiles": [ + "/node_modules/@wordpress/jest-preset-default/scripts/setup-globals.js" + ], + "setupFilesAfterEnv": [ + "/node_modules/@wordpress/jest-preset-default/scripts/setup-test-framework.js" + ], + "snapshotSerializers": [ + "/node_modules/enzyme-to-json/serializer.js" + ], + "testMatch": [ + "**/__tests__/**/*.[jt]s", + "**/test/*.[jt]s", + "**/?(*.)test.[jt]s" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/wordpress/" + ], + "timers": "fake", + "transform": { + "^.+\\.[jt]sx?$": "/node_modules/babel-jest" + }, + "verbose": true +} diff --git a/gb-src/packages/jest-preset-default/package.json b/gb-src/packages/jest-preset-default/package.json new file mode 100644 index 0000000000000..bbe4cabf60766 --- /dev/null +++ b/gb-src/packages/jest-preset-default/package.json @@ -0,0 +1,46 @@ +{ + "name": "@wordpress/jest-preset-default", + "version": "5.1.1", + "description": "Default Jest preset for WordPress development.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "jest", + "jest-preset", + "preset", + "react", + "enzyme" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/jest-preset-default/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/jest-preset-default" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "scripts", + "index.js", + "jest-preset.json" + ], + "main": "index.js", + "dependencies": { + "@wordpress/jest-console": "file:../jest-console", + "babel-jest": "^24.7.1", + "enzyme": "^3.9.0", + "enzyme-adapter-react-16": "^1.10.0", + "enzyme-to-json": "^3.3.5" + }, + "peerDependencies": { + "jest": ">=24" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/jest-preset-default/scripts/setup-globals.js b/gb-src/packages/jest-preset-default/scripts/setup-globals.js new file mode 100644 index 0000000000000..e03f036a0029b --- /dev/null +++ b/gb-src/packages/jest-preset-default/scripts/setup-globals.js @@ -0,0 +1,24 @@ +// These are necessary to load TinyMCE successfully +global.URL = window.URL; +global.window.tinyMCEPreInit = { + // Without this, TinyMCE tries to determine its URL by looking at the + // ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [ , ], + replacements: [ + { + attributes: { 'data-rich-text-script': 'alert(%221%22)' }, + type: 'script', + }, + ], + text: '\ufffc', + }, + }, + { + description: 'should disarm on* attribute', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [ , ], + replacements: [ + { + attributes: { + 'data-disable-rich-text-onerror': "alert('1')", + }, + type: 'img', + }, + ], + text: '\ufffc', + }, + }, +]; + +export const specWithRegistration = [ + { + description: 'should create format by matching the class', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + }, + html: 'a', + value: { + formats: [ [ { + type: 'my-plugin/link', + attributes: {}, + unregisteredAttributes: {}, + } ] ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should retain class names', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + }, + html: 'a', + value: { + formats: [ [ { + type: 'my-plugin/link', + attributes: {}, + unregisteredAttributes: { + class: 'test', + }, + } ] ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should create base format', + formatName: 'core/link', + formatType: { + title: 'Link', + tagName: 'a', + className: null, + edit() {}, + }, + html: 'a', + value: { + formats: [ [ { + type: 'core/link', + attributes: {}, + unregisteredAttributes: { + class: 'custom-format', + }, + } ] ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should create fallback format', + html: 'a', + value: { + formats: [ [ { + type: 'a', + attributes: { + class: 'custom-format', + }, + } ] ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should not create format if editable tree only', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + __experimentalCreatePrepareEditableTree() {}, + }, + html: 'a', + value: { + formats: [ , ], + replacements: [ , ], + text: 'a', + }, + noToHTMLString: true, + }, + { + description: 'should create format if editable tree only but changes need to be recorded', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + __experimentalCreatePrepareEditableTree() {}, + __experimentalCreateOnChangeEditableValue() {}, + }, + html: 'a', + value: { + formats: [ [ { + type: 'my-plugin/link', + attributes: {}, + unregisteredAttributes: {}, + } ] ], + replacements: [ , ], + text: 'a', + }, + }, +]; diff --git a/gb-src/packages/rich-text/src/test/indent-list-items.js b/gb-src/packages/rich-text/src/test/indent-list-items.js new file mode 100644 index 0000000000000..349174247cc42 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/indent-list-items.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { indentListItems } from '../indent-list-items'; +import { getSparseArrayLength } from './helpers'; +import { LINE_SEPARATOR } from '../special-characters'; + +describe( 'indentListItems', () => { + const ul = { type: 'ul' }; + const ol = { type: 'ol' }; + + it( 'should not indent only item', () => { + const record = { + replacements: [ , ], + text: '1', + start: 1, + end: 1, + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( record ); + expect( result ).toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); + + it( 'should indent', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }`; + const record = { + replacements: [ , , ], + text, + start: 2, + end: 2, + }; + const expected = { + ...record, + replacements: [ , [ ul ] ], + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 1 ); + } ); + + it( 'should not indent without target list', () => { + const record = { + replacements: [ , [ ul ] ], + text: `1${ LINE_SEPARATOR }`, + start: 2, + end: 2, + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( record ); + expect( result ).toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 1 ); + } ); + + it( 'should indent and merge with previous list', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }${ LINE_SEPARATOR }`; + const record = { + replacements: [ , [ ol ], , ], + text, + start: 3, + end: 3, + }; + const expected = { + ...record, + replacements: [ , [ ol ], [ ol ] ], + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should indent already indented item', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3`; + const record = { + replacements: [ , [ ul ], , [ ul ], , ], + text, + start: 5, + end: 5, + }; + const expected = { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , ], + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should indent with multiple lines selected', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3`; + const record = { + replacements: [ , , , [ ul ], , ], + text, + start: 2, + end: 5, + }; + const expected = { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , ], + }; + const result = indentListItems( deepFreeze( record ), ul ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should indent one level at a time', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3${ LINE_SEPARATOR }4`; + const record = { + replacements: [ , [ ul ], , [ ul, ul ], , , , ], + text, + start: 6, + end: 6, + }; + + const result1 = indentListItems( deepFreeze( record ), ul ); + + expect( result1 ).not.toBe( record ); + expect( getSparseArrayLength( result1.replacements ) ).toBe( 3 ); + expect( result1 ).toEqual( { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , [ ul ], , ], + } ); + + const result2 = indentListItems( deepFreeze( result1 ), ul ); + + expect( result2 ).not.toBe( result1 ); + expect( getSparseArrayLength( result2.replacements ) ).toBe( 3 ); + expect( result2 ).toEqual( { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , [ ul, ul ], , ], + } ); + + const result3 = indentListItems( deepFreeze( result2 ), ul ); + + expect( result3 ).not.toBe( result2 ); + expect( getSparseArrayLength( result3.replacements ) ).toBe( 3 ); + expect( result3 ).toEqual( { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , [ ul, ul, ul ], , ], + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/insert-line-separator.js b/gb-src/packages/rich-text/src/test/insert-line-separator.js new file mode 100644 index 0000000000000..497555cc4a01a --- /dev/null +++ b/gb-src/packages/rich-text/src/test/insert-line-separator.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { insertLineSeparator } from '../insert-line-separator'; +import { LINE_SEPARATOR } from '../special-characters'; +import { getSparseArrayLength } from './helpers'; + +describe( 'insertLineSeparator', () => { + const ol = { type: 'ol' }; + + it( 'should insert line separator at end', () => { + const value = { + formats: [ , ], + replacements: [ , ], + text: '1', + start: 1, + end: 1, + }; + const expected = { + formats: [ , , ], + replacements: [ , , ], + text: `1${ LINE_SEPARATOR }`, + start: 2, + end: 2, + }; + const result = insertLineSeparator( deepFreeze( value ) ); + + expect( result ).not.toBe( value ); + expect( result ).toEqual( expected ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); + + it( 'should insert line separator at start', () => { + const value = { + formats: [ , ], + replacements: [ , ], + text: '1', + start: 0, + end: 0, + }; + const expected = { + formats: [ , , ], + replacements: [ , , ], + text: `${ LINE_SEPARATOR }1`, + start: 1, + end: 1, + }; + const result = insertLineSeparator( deepFreeze( value ) ); + + expect( result ).not.toBe( value ); + expect( result ).toEqual( expected ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); + + it( 'should insert line separator with previous line separator formats', () => { + const value = { + formats: [ , , , , , ], + replacements: [ , , , [ ol ], , ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a`, + start: 5, + end: 5, + }; + const expected = { + formats: [ , , , , , , ], + replacements: [ , , , [ ol ], , [ ol ] ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a${ LINE_SEPARATOR }`, + start: 6, + end: 6, + }; + const result = insertLineSeparator( deepFreeze( value ) ); + + expect( result ).not.toBe( value ); + expect( result ).toEqual( expected ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should insert line separator without formats if previous line separator did not have any', () => { + const value = { + formats: [ , , , , , ], + replacements: [ , , , , , ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a`, + start: 5, + end: 5, + }; + const expected = { + formats: [ , , , , , , ], + replacements: [ , , , , , , ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a${ LINE_SEPARATOR }`, + start: 6, + end: 6, + }; + const result = insertLineSeparator( deepFreeze( value ) ); + + expect( result ).not.toBe( value ); + expect( result ).toEqual( expected ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/insert-object.js b/gb-src/packages/rich-text/src/test/insert-object.js new file mode 100644 index 0000000000000..7c2ba57806ad0 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/insert-object.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { insertObject } from '../insert-object'; +import { getSparseArrayLength } from './helpers'; +import { OBJECT_REPLACEMENT_CHARACTER } from '../special-characters'; + +describe( 'insert', () => { + const obj = { type: 'obj' }; + const em = { type: 'em' }; + + it( 'should delete and insert', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , [ em ], , , , , , , ], + replacements: [ , , obj, , , , , , , , ], + text: `on${ OBJECT_REPLACEMENT_CHARACTER }o three`, + start: 3, + end: 3, + }; + const result = insertObject( deepFreeze( record ), obj, 2, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 1 ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 1 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/insert.js b/gb-src/packages/rich-text/src/test/insert.js new file mode 100644 index 0000000000000..cae4cc5b85d5f --- /dev/null +++ b/gb-src/packages/rich-text/src/test/insert.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { insert } from '../insert'; +import { getSparseArrayLength } from './helpers'; + +describe( 'insert', () => { + const em = { type: 'em' }; + const strong = { type: 'strong' }; + + it( 'should delete and insert', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [], + text: 'one two three', + start: 6, + end: 6, + }; + const toInsert = { + formats: [ [ strong ] ], + replacements: [], + text: 'a', + }; + const expected = { + formats: [ , , [ strong ], [ em ], , , , , , , ], + replacements: [], + text: 'onao three', + start: 3, + end: 3, + }; + const result = insert( deepFreeze( record ), toInsert, 2, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should insert line break with selection', () => { + const record = { + formats: [ , , ], + replacements: [], + text: 'tt', + start: 1, + end: 1, + }; + const toInsert = { + formats: [ , ], + replacements: [], + text: '\n', + }; + const expected = { + formats: [ , , , ], + replacements: [], + text: 't\nt', + start: 2, + end: 2, + }; + const result = insert( deepFreeze( record ), toInsert ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/is-collapsed.js b/gb-src/packages/rich-text/src/test/is-collapsed.js new file mode 100644 index 0000000000000..e32ef447fea52 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/is-collapsed.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ + +import { isCollapsed } from '../is-collapsed'; + +describe( 'isCollapsed', () => { + it( 'should return true for a collapsed selection', () => { + const record = { + start: 4, + end: 4, + }; + + expect( isCollapsed( record ) ).toBe( true ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/is-empty.js b/gb-src/packages/rich-text/src/test/is-empty.js new file mode 100644 index 0000000000000..0080ba3a0914f --- /dev/null +++ b/gb-src/packages/rich-text/src/test/is-empty.js @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ + +import { isEmpty, isEmptyLine } from '../is-empty'; + +describe( 'isEmpty', () => { + it( 'should return true', () => { + const one = { + formats: [], + text: '', + }; + + expect( isEmpty( one ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [], + text: 'test', + }; + + expect( isEmpty( one ) ).toBe( false ); + } ); +} ); + +describe( 'isEmptyLine', () => { + it( 'should return true', () => { + const one = { + formats: [], + text: '', + start: 0, + end: 0, + }; + const two = { + formats: [ , , ], + text: '\u2028', + start: 0, + end: 0, + }; + const three = { + formats: [ , , ], + text: '\u2028', + start: 1, + end: 1, + }; + const four = { + formats: [ , , , , ], + text: '\u2028\u2028', + start: 1, + end: 1, + }; + const five = { + formats: [ , , , , ], + text: 'a\u2028\u2028b', + start: 2, + end: 2, + }; + + expect( isEmptyLine( one ) ).toBe( true ); + expect( isEmptyLine( two ) ).toBe( true ); + expect( isEmptyLine( three ) ).toBe( true ); + expect( isEmptyLine( four ) ).toBe( true ); + expect( isEmptyLine( five ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [ , , , , ], + text: '\u2028a\u2028', + start: 1, + end: 1, + }; + const two = { + formats: [ , , , , ], + text: '\u2028\n', + start: 1, + end: 1, + }; + + expect( isEmptyLine( one ) ).toBe( false ); + expect( isEmptyLine( two ) ).toBe( false ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/is-format-equal.js b/gb-src/packages/rich-text/src/test/is-format-equal.js new file mode 100644 index 0000000000000..baad9a435c618 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/is-format-equal.js @@ -0,0 +1,70 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from '../is-format-equal'; + +describe( 'isFormatEqual', () => { + const spec = [ + { + format1: undefined, + format2: undefined, + isEqual: true, + description: 'should return true if both are undefined', + }, + { + format1: {}, + format2: undefined, + isEqual: false, + description: 'should return false if one is undefined', + }, + { + format1: { type: 'bold' }, + format2: { type: 'bold' }, + isEqual: true, + description: 'should return true if both have same type', + }, + { + format1: { type: 'bold' }, + format2: { type: 'italic' }, + isEqual: false, + description: 'should return false if one has different type', + }, + { + format1: { type: 'bold', attributes: {} }, + format2: { type: 'bold' }, + isEqual: false, + description: 'should return false if one has undefined attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1' } }, + isEqual: true, + description: 'should return true if both have same attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { b: '1' } }, + isEqual: false, + description: 'should return false if one has different attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: false, + description: 'should return false if one has a different amount of attributes', + }, + { + format1: { type: 'bold', attributes: { b: '1', a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: true, + description: 'should return true both have same attributes but different order', + }, + ]; + + spec.forEach( ( { format1, format2, isEqual, description } ) => { + it( description, () => { + expect( isFormatEqual( format1, format2 ) ).toBe( isEqual ); + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/join.js b/gb-src/packages/rich-text/src/test/join.js new file mode 100644 index 0000000000000..84801125cc2b0 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/join.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { join } from '../join'; +import { getSparseArrayLength } from './helpers'; + +describe( 'join', () => { + const em = { type: 'em' }; + const separators = [ + ' ', + { + formats: [ , ], + replacements: [ , ], + text: ' ', + }, + ]; + + separators.forEach( ( separator ) => { + it( 'should join records with string separator', () => { + const one = { + formats: [ , , [ em ] ], + replacements: [ , , , ], + text: 'one', + }; + const two = { + formats: [ [ em ], , , ], + replacements: [ , , , ], + text: 'two', + }; + const three = { + formats: [ , , [ em ], , [ em ], , , ], + replacements: [ , , , , , , , ], + text: 'one two', + }; + const result = join( [ deepFreeze( one ), deepFreeze( two ) ], separator ); + + expect( result ).not.toBe( one ); + expect( result ).not.toBe( two ); + expect( result ).toEqual( three ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/normalise-formats.js b/gb-src/packages/rich-text/src/test/normalise-formats.js new file mode 100644 index 0000000000000..c71b8a35ed634 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/normalise-formats.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { normaliseFormats } from '../normalise-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'normaliseFormats', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should normalise formats', () => { + const record = { + formats: [ , [ em ], [ { ...em }, { ...strong } ], [ em, strong ], , [ { ...em } ] ], + text: 'one two three', + }; + const result = normaliseFormats( deepFreeze( record ) ); + + expect( result ).toEqual( record ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).not.toBe( result.formats[ 5 ][ 0 ] ); + expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/outdent-list-items.js b/gb-src/packages/rich-text/src/test/outdent-list-items.js new file mode 100644 index 0000000000000..e4325dfb61c90 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/outdent-list-items.js @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { outdentListItems } from '../outdent-list-items'; +import { getSparseArrayLength } from './helpers'; +import { LINE_SEPARATOR } from '../special-characters'; + +describe( 'outdentListItems', () => { + const ul = { type: 'ul' }; + + it( 'should not outdent only item', () => { + const record = { + replacements: [ , ], + text: '1', + start: 1, + end: 1, + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( record ); + expect( result ).toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); + + it( 'should indent', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }`; + const record = { + replacements: [ , [ ul ] ], + text, + start: 2, + end: 2, + }; + const expected = { + ...record, + replacements: [ , , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); + + it( 'should outdent two levels deep', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3`; + const record = { + replacements: [ , [ ul ], , [ ul, ul ], , ], + text, + start: 5, + end: 5, + }; + const expected = { + ...record, + replacements: [ , [ ul ], , [ ul ], , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should outdent with multiple lines selected', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3`; + const record = { + replacements: [ , [ ul ], , [ ul, ul ], , ], + text, + start: 2, + end: 5, + }; + const expected = { + ...record, + replacements: [ , , , [ ul ], , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 1 ); + } ); + + it( 'should outdent list item with children', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3${ LINE_SEPARATOR }4`; + const record = { + replacements: [ , [ ul ], , [ ul, ul ], , [ ul, ul ], , ], + text, + start: 2, + end: 2, + }; + const expected = { + ...record, + replacements: [ , , , [ ul ], , [ ul ], , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should outdent list based on parent list', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3${ LINE_SEPARATOR }4`; + const record = { + replacements: [ , [ ul ], , [ ul, ul ], , [ ul ], , ], + text, + start: 6, + end: 6, + }; + const expected = { + ...record, + replacements: [ , [ ul ], , [ ul, ul ], , , , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 2 ); + } ); + + it( 'should outdent when a selected item is at level 0', () => { + // As we're testing list formats, the text should remain the same. + const text = `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }3`; + const record = { + replacements: [ , [ ul ], , , , ], + text, + start: 2, + end: 5, + }; + const expected = { + ...record, + replacements: [ , , , , , ], + }; + const result = outdentListItems( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 0 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/register-format-type.js b/gb-src/packages/rich-text/src/test/register-format-type.js new file mode 100644 index 0000000000000..5fc78630be2ba --- /dev/null +++ b/gb-src/packages/rich-text/src/test/register-format-type.js @@ -0,0 +1,195 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { registerFormatType } from '../register-format-type'; +import { unregisterFormatType } from '../unregister-format-type'; +import { getFormatType } from '../get-format-type'; + +describe( 'registerFormatType', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + + afterEach( () => { + select( 'core/rich-text' ).getFormatTypes().forEach( ( { name } ) => { + unregisterFormatType( name ); + } ); + } ); + + const validName = 'plugin/test'; + const validSettings = { + tagName: 'test', + className: null, + title: 'Test', + edit() {}, + }; + + it( 'should register format', () => { + const settings = registerFormatType( validName, validSettings ); + expect( settings ).toEqual( { ...validSettings, name: validName } ); + expect( console ).not.toHaveErrored(); + } ); + + it( 'should error without arguments', () => { + const format = registerFormatType(); + expect( console ).toHaveErroredWith( 'Format names must be strings.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject format types without a namespace', () => { + const format = registerFormatType( 'doing-it-wrong' ); + expect( console ).toHaveErroredWith( 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject format types with too many namespaces', () => { + const format = registerFormatType( 'doing/it/wrong' ); + expect( console ).toHaveErroredWith( 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject format types with invalid characters', () => { + const format = registerFormatType( 'still/_doing_it_wrong' ); + expect( console ).toHaveErroredWith( 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject format types with uppercase characters', () => { + const format = registerFormatType( 'Core/Bold' ); + expect( console ).toHaveErroredWith( 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject format types not starting with a letter', () => { + const format = registerFormatType( 'my-plugin/4-fancy-format' ); + expect( console ).toHaveErroredWith( 'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject numbers', () => { + const format = registerFormatType( 42 ); + expect( console ).toHaveErroredWith( 'Format names must be strings.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should accept valid format names', () => { + const format = registerFormatType( 'my-plugin/fancy-format-4', validSettings ); + expect( console ).not.toHaveErrored(); + expect( format ).toEqual( { + name: 'my-plugin/fancy-format-4', + ...validSettings, + } ); + } ); + + it( 'should error on already registered name', () => { + registerFormatType( validName, validSettings ); + const duplicateFormat = registerFormatType( validName, validSettings ); + expect( console ).toHaveErroredWith( 'Format "plugin/test" is already registered.' ); + expect( duplicateFormat ).toBeUndefined(); + } ); + + it( 'should reject formats without tag name', () => { + const settings = { ...validSettings }; + delete settings.tagName; + const format = registerFormatType( validName, settings ); + expect( console ).toHaveErroredWith( 'Format tag names must be a string.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should error on empty tagName property', () => { + const format = registerFormatType( validName, { + ...validSettings, + tagName: '', + } ); + expect( console ).toHaveErroredWith( 'Format tag names must be a string.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject formats without class name', () => { + const settings = { ...validSettings }; + delete settings.className; + const format = registerFormatType( validName, settings ); + expect( console ).toHaveErroredWith( 'Format class names must be a string, or null to handle bare elements.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should error on invalid empty className property', () => { + const format = registerFormatType( validName, { + ...validSettings, + className: '', + } ); + expect( console ).toHaveErroredWith( 'Format class names must be a string, or null to handle bare elements.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should error on invalid className property', () => { + const format = registerFormatType( validName, { + ...validSettings, + className: 'invalid class name', + } ); + expect( console ).toHaveErroredWith( 'A class name must begin with a letter, followed by any number of hyphens, letters, or numbers.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should error on already registered tagName', () => { + registerFormatType( validName, validSettings ); + const duplicateTagNameFormat = registerFormatType( 'plugin/second', validSettings ); + expect( console ).toHaveErroredWith( 'Format "plugin/test" is already registered to handle bare tag name "test".' ); + expect( duplicateTagNameFormat ).toBeUndefined(); + } ); + + it( 'should error on already registered className', () => { + registerFormatType( validName, { + ...validSettings, + className: 'test', + } ); + const duplicateClassNameFormat = registerFormatType( 'plugin/second', { + ...validSettings, + className: 'test', + } ); + expect( console ).toHaveErroredWith( 'Format "plugin/test" is already registered to handle class name "test".' ); + expect( duplicateClassNameFormat ).toBeUndefined(); + } ); + + it( 'should reject formats without title', () => { + const settings = { ...validSettings }; + delete settings.title; + const format = registerFormatType( validName, settings ); + expect( console ).toHaveErroredWith( `The format "${ validName }" must have a title.` ); + expect( format ).toBeUndefined(); + } ); + + it( 'should error on empty title property', () => { + const format = registerFormatType( validName, { + ...validSettings, + title: '', + } ); + expect( console ).toHaveErroredWith( 'The format "plugin/test" must have a title.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should reject titles which are not strings', () => { + const format = registerFormatType( validName, { + ...validSettings, + title: 1337, + } ); + expect( console ).toHaveErroredWith( 'Format titles must be strings.' ); + expect( format ).toBeUndefined(); + } ); + + it( 'should store a copy of the format type', () => { + const formatType = { ...validSettings }; + registerFormatType( validName, formatType ); + formatType.mutated = true; + expect( getFormatType( validName ) ).toEqual( { + name: validName, + ...validSettings, + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/remove-format.js b/gb-src/packages/rich-text/src/test/remove-format.js new file mode 100644 index 0000000000000..343eb8c47e011 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/remove-format.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { removeFormat } from '../remove-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'removeFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should remove format', () => { + const record = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); + + it( 'should remove format for collased selection', () => { + const record = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 4, 4 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/replace.js b/gb-src/packages/rich-text/src/test/replace.js new file mode 100644 index 0000000000000..6cfd4dcc9e48b --- /dev/null +++ b/gb-src/packages/rich-text/src/test/replace.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { replace } from '../replace'; +import { getSparseArrayLength } from './helpers'; + +describe( 'replace', () => { + const em = { type: 'em' }; + + it( 'should replace string to string', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', '2' ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 1 ); + } ); + + it( 'should replace string to record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const replacement = { + formats: [ , ], + replacements: [ , ], + text: '2', + }; + const expected = { + formats: [ , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', replacement ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should replace string to function', () => { + const record = { + formats: [ , , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , , ], + text: 'abc12345#$*%', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , , , , , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , , , , , , , , ], + text: 'abc - 12345 - #$*%', + start: 18, + end: 18, + }; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace + const result = replace( deepFreeze( record ), /([^\d]*)(\d*)([^\w]*)/, ( match, p1, p2, p3 ) => { + return [ p1, p2, p3 ].join( ' - ' ); + } ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/slice.js b/gb-src/packages/rich-text/src/test/slice.js new file mode 100644 index 0000000000000..b181e5e9be817 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/slice.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { slice } from '../slice'; +import { getSparseArrayLength } from './helpers'; + +describe( 'slice', () => { + const em = { type: 'em' }; + + it( 'should slice', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , [ em ], [ em ] ], + replacements: [ , , , ], + text: ' tw', + }; + const result = slice( deepFreeze( record ), 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should slice record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , [ em ], [ em ] ], + replacements: [ , , , ], + text: ' tw', + }; + const result = slice( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/split.js b/gb-src/packages/rich-text/src/test/split.js new file mode 100644 index 0000000000000..8eef998e488d6 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/split.js @@ -0,0 +1,231 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { split } from '../split'; +import { getSparseArrayLength } from './helpers'; + +describe( 'split', () => { + const em = { type: 'em' }; + + it( 'should split', () => { + const record = { + start: 5, + end: 10, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + replacements: [ , , , , , , ], + text: 'one tw', + }, + { + start: 0, + end: 0, + formats: [ [ em ], , , , , , , ], + replacements: [ , , , , , , , ], + text: 'o three', + }, + ]; + const result = split( deepFreeze( record ), 6, 6 ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split with selection', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + replacements: [ , , , , , , ], + text: 'one tw', + }, + { + formats: [ [ em ], , , , , , , ], + replacements: [ , , , , , , , ], + text: 'o three', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split empty', () => { + const record = { + formats: [], + replacements: [], + text: '', + start: 0, + end: 0, + }; + const expected = [ + { + formats: [], + replacements: [], + text: '', + }, + { + formats: [], + replacements: [], + text: '', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split multiline', () => { + const record = { + formats: [ , , , , , , , , , , ], + replacements: [ , , , , , , , , , , ], + text: 'test\u2028\u2028test', + start: 5, + end: 5, + }; + const expected = [ + { + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'test', + }, + { + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'test', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split search', () => { + const record = { + start: 6, + end: 16, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , , , , , , , , , , , , , ], + text: 'one two three four five', + }; + const expected = [ + { + formats: [ , , , ], + replacements: [ , , , ], + text: 'one', + }, + { + start: 2, + end: 3, + formats: [ [ em ], [ em ], [ em ] ], + replacements: [ , , , ], + text: 'two', + }, + { + start: 0, + end: 5, + formats: [ , , , , , ], + replacements: [ , , , , , ], + text: 'three', + }, + { + start: 0, + end: 2, + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'four', + }, + { + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'five', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split search 2', () => { + const record = { + start: 5, + end: 6, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , ], + replacements: [ , , , ], + text: 'one', + }, + { + start: 1, + end: 2, + formats: [ [ em ], [ em ], [ em ] ], + replacements: [ , , , ], + text: 'two', + }, + { + formats: [ , , , , , ], + replacements: [ , , , , , ], + text: 'three', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/to-dom.js b/gb-src/packages/rich-text/src/test/to-dom.js new file mode 100644 index 0000000000000..2036ba24b80f7 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/to-dom.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ + +import { JSDOM } from 'jsdom'; + +/** + * Internal dependencies + */ + +import { toDom, applyValue } from '../to-dom'; +import { createElement } from '../create-element'; +import { spec } from './helpers'; + +const { window } = new JSDOM(); +const { document } = window; + +describe( 'recordToDom', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + + spec.forEach( ( { + description, + multilineTag, + record, + startPath, + endPath, + } ) => { + it( description, () => { + const { body, selection } = toDom( { + value: record, + multilineTag, + } ); + expect( body ).toMatchSnapshot(); + expect( selection ).toEqual( { startPath, endPath } ); + } ); + } ); +} ); + +describe( 'applyValue', () => { + const cases = [ + { + current: 'test', + future: '', + movedCount: 0, + description: 'should remove nodes', + }, + { + current: '', + future: 'test', + movedCount: 1, + description: 'should add nodes', + }, + { + current: 'test', + future: 'test', + movedCount: 0, + description: 'should not modify', + }, + { + current: 'b', + future: 'b', + movedCount: 0, + description: 'should remove attribute', + }, + { + current: 'b', + future: 'b', + movedCount: 0, + description: 'should remove attributes', + }, + { + current: 'a', + future: 'c', + movedCount: 0, + description: 'should add attribute', + }, + { + current: 'a', + future: 'c', + movedCount: 0, + description: 'should add attributes', + }, + { + current: 'a', + future: 'a', + movedCount: 0, + description: 'should update attribute', + }, + { + current: 'a', + future: 'a', + movedCount: 0, + description: 'should update attributes', + }, + ]; + + cases.forEach( ( { current, future, description, movedCount } ) => { + it( description, () => { + const body = createElement( document, current ).cloneNode( true ); + const futureBody = createElement( document, future ).cloneNode( true ); + const childNodes = Array.from( futureBody.childNodes ); + applyValue( futureBody, body ); + const count = childNodes.reduce( ( acc, { parentNode } ) => { + return parentNode === body ? acc + 1 : acc; + }, 0 ); + expect( body.innerHTML ).toEqual( future ); + expect( count ).toEqual( movedCount ); + } ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/to-html-string.js b/gb-src/packages/rich-text/src/test/to-html-string.js new file mode 100644 index 0000000000000..bd8e846cb13ef --- /dev/null +++ b/gb-src/packages/rich-text/src/test/to-html-string.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ + +import { JSDOM } from 'jsdom'; + +/** + * Internal dependencies + */ + +import { create } from '../create'; +import { toHTMLString } from '../to-html-string'; +import { registerFormatType } from '../register-format-type'; +import { unregisterFormatType } from '../unregister-format-type'; +import { specWithRegistration } from './helpers'; + +const { window } = new JSDOM(); +const { document } = window; + +function createNode( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + doc.body.innerHTML = HTML; + return doc.body.firstChild; +} + +describe( 'toHTMLString', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + + specWithRegistration.forEach( ( { + description, + formatName, + formatType, + html, + value, + noToHTMLString, + } ) => { + if ( noToHTMLString ) { + return; + } + + it( description, () => { + if ( formatName ) { + registerFormatType( formatName, formatType ); + } + + const result = toHTMLString( { value } ); + + if ( formatName ) { + unregisterFormatType( formatName ); + } + + expect( result ).toEqual( html ); + } ); + } ); + + it( 'should extract recreate HTML 1', () => { + const HTML = 'one two 🍒 three'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 2', () => { + const HTML = 'one two 🍒 test three'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 3', () => { + const HTML = ''; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 4', () => { + const HTML = 'two 🍒'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 5', () => { + const HTML = 'If you want to learn more about how to build additional blocks, or if you are interested in helping with the project, head over to the GitHub repository.'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 6', () => { + const HTML = '
  • one
    • two
  • three
  • '; + const element = createNode( `
      ${ HTML }
    ` ); + const multilineTag = 'li'; + const multilineWrapperTags = [ 'ul', 'ol' ]; + const value = create( { element, multilineTag, multilineWrapperTags } ); + const result = toHTMLString( { value, multilineTag, multilineWrapperTags } ); + + expect( result ).toEqual( HTML ); + } ); + + it( 'should serialize neighbouring formats of same type', () => { + const HTML = 'aa'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( HTML ); + } ); + + it( 'should serialize neighbouring same formats', () => { + const HTML = 'aa'; + const element = createNode( `

    ${ HTML }

    ` ); + const expectedHTML = 'aa'; + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( expectedHTML ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/toggle-format.js b/gb-src/packages/rich-text/src/test/toggle-format.js new file mode 100644 index 0000000000000..9613e970d40a4 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/toggle-format.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { toggleFormat } from '../toggle-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'toggleFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should remove format if it exists at start of selection', () => { + const record = { + formats: [ , , , [ strong ], [ em, strong ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + start: 3, + end: 6, + }; + const result = toggleFormat( deepFreeze( record ), strong ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); + + it( 'should apply format if it doesn\'t exist at start of selection', () => { + const record = { + formats: [ , , , , [ em, strong ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , , , [ strong ], [ strong, em ], [ strong, em ], [ em ], , , , , , , ], + activeFormats: [ strong ], + text: 'one two three', + start: 3, + end: 6, + }; + const result = toggleFormat( deepFreeze( record ), strong ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/unregister-format-type.js b/gb-src/packages/rich-text/src/test/unregister-format-type.js new file mode 100644 index 0000000000000..82774f5395d9d --- /dev/null +++ b/gb-src/packages/rich-text/src/test/unregister-format-type.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { unregisterFormatType } from '../unregister-format-type'; +import { registerFormatType } from '../register-format-type'; +import { getFormatTypes } from '../get-format-types'; +import { getFormatType } from '../get-format-type'; + +describe( 'unregisterFormatType', () => { + const defaultFormatSettings = { + edit: noop, + title: 'format title', + tagName: 'test', + className: null, + }; + + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + + afterEach( () => { + getFormatTypes().forEach( ( format ) => { + unregisterFormatType( format.name ); + } ); + } ); + + it( 'should fail if the format is not registered', () => { + const oldFormat = unregisterFormatType( 'core/test-format' ); + expect( console ).toHaveErroredWith( 'Format core/test-format is not registered.' ); + expect( oldFormat ).toBeUndefined(); + } ); + + it( 'should unregister existing formats', () => { + registerFormatType( 'core/test-format', defaultFormatSettings ); + expect( getFormatType( 'core/test-format' ) ).toEqual( { + name: 'core/test-format', + ...defaultFormatSettings, + } ); + const oldFormat = unregisterFormatType( 'core/test-format' ); + expect( console ).not.toHaveErrored(); + expect( oldFormat ).toEqual( { + name: 'core/test-format', + ...defaultFormatSettings, + } ); + expect( getFormatTypes() ).toEqual( [] ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/test/update-formats.js b/gb-src/packages/rich-text/src/test/update-formats.js new file mode 100644 index 0000000000000..e5c3c97a85bb7 --- /dev/null +++ b/gb-src/packages/rich-text/src/test/update-formats.js @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ + +import { updateFormats } from '../update-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'updateFormats', () => { + const em = { type: 'em' }; + + it( 'should update formats with empty array', () => { + const value = { + formats: [ [ em ] ], + text: '1', + }; + const expected = { + ...value, + activeFormats: [], + formats: [ , ], + }; + const result = updateFormats( { + value, + start: 0, + end: 1, + formats: [], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should update formats and update references', () => { + const value = { + formats: [ [ em ], , ], + text: '123', + }; + const expected = { + ...value, + activeFormats: [ em ], + formats: [ [ em ], [ em ] ], + }; + const result = updateFormats( { + value, + start: 1, + end: 2, + formats: [ { ...em } ], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( result.formats[ 1 ][ 0 ] ).toBe( em ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/gb-src/packages/rich-text/src/to-dom.js b/gb-src/packages/rich-text/src/to-dom.js new file mode 100644 index 0000000000000..9d7aed3cfdfd0 --- /dev/null +++ b/gb-src/packages/rich-text/src/to-dom.js @@ -0,0 +1,297 @@ +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; +import { createElement } from './create-element'; + +/** + * Browser dependencies + */ + +const { TEXT_NODE } = window.Node; + +/** + * Creates a path as an array of indices from the given root node to the given + * node. + * + * @param {Node} node Node to find the path of. + * @param {HTMLElement} rootNode Root node to find the path from. + * @param {Array} path Initial path to build on. + * + * @return {Array} The path from the root node to the node. + */ +function createPathToNode( node, rootNode, path ) { + const parentNode = node.parentNode; + let i = 0; + + while ( ( node = node.previousSibling ) ) { + i++; + } + + path = [ i, ...path ]; + + if ( parentNode !== rootNode ) { + path = createPathToNode( parentNode, rootNode, path ); + } + + return path; +} + +/** + * Gets a node given a path (array of indices) from the given node. + * + * @param {HTMLElement} node Root node to find the wanted node in. + * @param {Array} path Path (indices) to the wanted node. + * + * @return {Object} Object with the found node and the remaining offset (if any). + */ +function getNodeByPath( node, path ) { + path = [ ...path ]; + + while ( node && path.length > 1 ) { + node = node.childNodes[ path.shift() ]; + } + + return { + node, + offset: path[ 0 ], + }; +} + +/** + * Returns a new instance of a DOM tree upon which RichText operations can be + * applied. + * + * Note: The current implementation will return a shared reference, reset on + * each call to `createEmpty`. Therefore, you should not hold a reference to + * the value to operate upon asynchronously, as it may have unexpected results. + * + * @return {WPRichTextTree} RichText tree. + */ +const createEmpty = () => createElement( document, '' ); + +function append( element, child ) { + if ( typeof child === 'string' ) { + child = element.ownerDocument.createTextNode( child ); + } + + const { type, attributes } = child; + + if ( type ) { + child = element.ownerDocument.createElement( type ); + + for ( const key in attributes ) { + child.setAttribute( key, attributes[ key ] ); + } + } + + return element.appendChild( child ); +} + +function appendText( node, text ) { + node.appendData( text ); +} + +function getLastChild( { lastChild } ) { + return lastChild; +} + +function getParent( { parentNode } ) { + return parentNode; +} + +function isText( { nodeType } ) { + return nodeType === TEXT_NODE; +} + +function getText( { nodeValue } ) { + return nodeValue; +} + +function remove( node ) { + return node.parentNode.removeChild( node ); +} + +export function toDom( { + value, + multilineTag, + prepareEditableTree, + isEditableTree = true, + placeholder, +} ) { + let startPath = []; + let endPath = []; + + if ( prepareEditableTree ) { + value = { + ...value, + formats: prepareEditableTree( value ), + }; + } + + const tree = toTree( { + value, + multilineTag, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex( body, pointer ) { + startPath = createPathToNode( pointer, body, [ pointer.nodeValue.length ] ); + }, + onEndIndex( body, pointer ) { + endPath = createPathToNode( pointer, body, [ pointer.nodeValue.length ] ); + }, + isEditableTree, + placeholder, + } ); + + return { + body: tree, + selection: { startPath, endPath }, + }; +} + +/** + * Create an `Element` tree from a Rich Text value and applies the difference to + * the `Element` tree contained by `current`. If a `multilineTag` is provided, + * text separated by two new lines will be wrapped in an `Element` of that type. + * + * @param {Object} $1 Named arguments. + * @param {Object} $1.value Value to apply. + * @param {HTMLElement} $1.current The live root node to apply the element tree to. + * @param {string} [$1.multilineTag] Multiline tag. + * @param {Array} [$1.multilineWrapperTags] Tags where lines can be found if nesting is possible. + */ +export function apply( { + value, + current, + multilineTag, + prepareEditableTree, + __unstableDomOnly, + placeholder, +} ) { + // Construct a new element tree in memory. + const { body, selection } = toDom( { + value, + multilineTag, + prepareEditableTree, + placeholder, + } ); + + applyValue( body, current ); + + if ( value.start !== undefined && ! __unstableDomOnly ) { + applySelection( selection, current ); + } +} + +export function applyValue( future, current ) { + let i = 0; + let futureChild; + + while ( ( futureChild = future.firstChild ) ) { + const currentChild = current.childNodes[ i ]; + + if ( ! currentChild ) { + current.appendChild( futureChild ); + } else if ( ! currentChild.isEqualNode( futureChild ) ) { + if ( + currentChild.nodeName !== futureChild.nodeName || + ( currentChild.nodeType === TEXT_NODE && currentChild.data !== futureChild.data ) + ) { + current.replaceChild( futureChild, currentChild ); + } else { + const currentAttributes = currentChild.attributes; + const futureAttributes = futureChild.attributes; + + if ( currentAttributes ) { + let ii = currentAttributes.length; + + // Reverse loop because `removeAttribute` on `currentChild` + // changes `currentAttributes`. + while ( ii-- ) { + const { name } = currentAttributes[ ii ]; + + if ( ! futureChild.getAttribute( name ) ) { + currentChild.removeAttribute( name ); + } + } + } + + if ( futureAttributes ) { + for ( let ii = 0; ii < futureAttributes.length; ii++ ) { + const { name, value } = futureAttributes[ ii ]; + + if ( currentChild.getAttribute( name ) !== value ) { + currentChild.setAttribute( name, value ); + } + } + } + + applyValue( futureChild, currentChild ); + future.removeChild( futureChild ); + } + } else { + future.removeChild( futureChild ); + } + + i++; + } + + while ( current.childNodes[ i ] ) { + current.removeChild( current.childNodes[ i ] ); + } +} + +/** + * Returns true if two ranges are equal, or false otherwise. Ranges are + * considered equal if their start and end occur in the same container and + * offset. + * + * @param {Range} a First range object to test. + * @param {Range} b First range object to test. + * + * @return {boolean} Whether the two ranges are equal. + */ +function isRangeEqual( a, b ) { + return ( + a.startContainer === b.startContainer && + a.startOffset === b.startOffset && + a.endContainer === b.endContainer && + a.endOffset === b.endOffset + ); +} + +export function applySelection( { startPath, endPath }, current ) { + const { node: startContainer, offset: startOffset } = getNodeByPath( current, startPath ); + const { node: endContainer, offset: endOffset } = getNodeByPath( current, endPath ); + const selection = window.getSelection(); + const { ownerDocument } = current; + const range = ownerDocument.createRange(); + + range.setStart( startContainer, startOffset ); + range.setEnd( endContainer, endOffset ); + + // Set back focus if focus is lost. + if ( ownerDocument.activeElement !== current ) { + current.focus(); + } + + if ( selection.rangeCount > 0 ) { + // If the to be added range and the live range are the same, there's no + // need to remove the live range and add the equivalent range. + if ( isRangeEqual( range, selection.getRangeAt( 0 ) ) ) { + return; + } + + selection.removeAllRanges(); + } + + selection.addRange( range ); +} diff --git a/gb-src/packages/rich-text/src/to-html-string.js b/gb-src/packages/rich-text/src/to-html-string.js new file mode 100644 index 0000000000000..2f91c509f1526 --- /dev/null +++ b/gb-src/packages/rich-text/src/to-html-string.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ + +import { + escapeHTML, + escapeAttribute, + isValidAttributeName, +} from '@wordpress/escape-html'; + +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; + +/** + * Create an HTML string from a Rich Text value. If a `multilineTag` is + * provided, text separated by a line separator will be wrapped in it. + * + * @param {Object} $1 Named argements. + * @param {Object} $1.value Rich text value. + * @param {string} [$1.multilineTag] Multiline tag. + * + * @return {string} HTML string. + */ +export function toHTMLString( { value, multilineTag } ) { + const tree = toTree( { + value, + multilineTag, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + } ); + + return createChildrenHTML( tree.children ); +} + +function createEmpty() { + return {}; +} + +function getLastChild( { children } ) { + return children && children[ children.length - 1 ]; +} + +function append( parent, object ) { + if ( typeof object === 'string' ) { + object = { text: object }; + } + + object.parent = parent; + parent.children = parent.children || []; + parent.children.push( object ); + return object; +} + +function appendText( object, text ) { + object.text += text; +} + +function getParent( { parent } ) { + return parent; +} + +function isText( { text } ) { + return typeof text === 'string'; +} + +function getText( { text } ) { + return text; +} + +function remove( object ) { + const index = object.parent.children.indexOf( object ); + + if ( index !== -1 ) { + object.parent.children.splice( index, 1 ); + } + + return object; +} + +function createElementHTML( { type, attributes, object, children } ) { + let attributeString = ''; + + for ( const key in attributes ) { + if ( ! isValidAttributeName( key ) ) { + continue; + } + + attributeString += ` ${ key }="${ escapeAttribute( attributes[ key ] ) }"`; + } + + if ( object ) { + return `<${ type }${ attributeString }>`; + } + + return `<${ type }${ attributeString }>${ createChildrenHTML( children ) }`; +} + +function createChildrenHTML( children = [] ) { + return children.map( ( child ) => { + if ( child.html !== undefined ) { + return child.html; + } + + return child.text === undefined ? createElementHTML( child ) : escapeHTML( child.text ); + } ).join( '' ); +} diff --git a/gb-src/packages/rich-text/src/to-tree.js b/gb-src/packages/rich-text/src/to-tree.js new file mode 100644 index 0000000000000..0482fb8954a98 --- /dev/null +++ b/gb-src/packages/rich-text/src/to-tree.js @@ -0,0 +1,319 @@ +/** + * Internal dependencies + */ + +import { getActiveFormats } from './get-active-formats'; +import { getFormatType } from './get-format-type'; +import { + LINE_SEPARATOR, + OBJECT_REPLACEMENT_CHARACTER, + ZWNBSP, +} from './special-characters'; + +function restoreOnAttributes( attributes, isEditableTree ) { + if ( isEditableTree ) { + return attributes; + } + + const newAttributes = {}; + + for ( const key in attributes ) { + let newKey = key; + if ( key.startsWith( 'data-disable-rich-text-' ) ) { + newKey = key.slice( 'data-disable-rich-text-'.length ); + } + + newAttributes[ newKey ] = attributes[ key ]; + } + + return newAttributes; +} + +/** + * Converts a format object to information that can be used to create an element + * from (type, attributes and object). + * + * @param {Object} $1 Named parameters. + * @param {string} $1.type The format type. + * @param {Object} $1.attributes The format attributes. + * @param {Object} $1.unregisteredAttributes The unregistered format + * attributes. + * @param {boolean} $1.object Wether or not it is an object + * format. + * @param {boolean} $1.boundaryClass Wether or not to apply a boundary + * class. + * @param {boolean} $1.isEditableTree + * @return {Object} Information to be used for + * element creation. + */ +function fromFormat( { type, attributes, unregisteredAttributes, object, boundaryClass, isEditableTree } ) { + const formatType = getFormatType( type ); + + let elementAttributes = {}; + + if ( boundaryClass ) { + elementAttributes[ 'data-rich-text-format-boundary' ] = 'true'; + } + + if ( ! formatType ) { + if ( attributes ) { + elementAttributes = { ...attributes, ...elementAttributes }; + } + + return { + type, + attributes: restoreOnAttributes( + elementAttributes, + isEditableTree + ), + object, + }; + } + + elementAttributes = { ...unregisteredAttributes, ...elementAttributes }; + + for ( const name in attributes ) { + const key = formatType.attributes ? formatType.attributes[ name ] : false; + + if ( key ) { + elementAttributes[ key ] = attributes[ name ]; + } else { + elementAttributes[ name ] = attributes[ name ]; + } + } + + if ( formatType.className ) { + if ( elementAttributes.class ) { + elementAttributes.class = `${ formatType.className } ${ elementAttributes.class }`; + } else { + elementAttributes.class = formatType.className; + } + } + + return { + type: formatType.tagName, + object: formatType.object, + attributes: restoreOnAttributes( elementAttributes, isEditableTree ), + }; +} + +export function toTree( { + value, + multilineTag, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex, + onEndIndex, + isEditableTree, + placeholder, +} ) { + const { formats, replacements, text, start, end } = value; + const formatsLength = formats.length + 1; + const tree = createEmpty(); + const multilineFormat = { type: multilineTag }; + const activeFormats = getActiveFormats( value ); + const deepestActiveFormat = activeFormats[ activeFormats.length - 1 ]; + + let lastSeparatorFormats; + let lastCharacterFormats; + let lastCharacter; + + // If we're building a multiline tree, start off with a multiline element. + if ( multilineTag ) { + append( append( tree, { type: multilineTag } ), '' ); + lastCharacterFormats = lastSeparatorFormats = [ multilineFormat ]; + } else { + append( tree, '' ); + } + + for ( let i = 0; i < formatsLength; i++ ) { + const character = text.charAt( i ); + const shouldInsertPadding = isEditableTree && ( + // Pad the line if the line is empty. + ! lastCharacter || + lastCharacter === LINE_SEPARATOR || + // Pad the line if the previous character is a line break, otherwise + // the line break won't be visible. + lastCharacter === '\n' + ); + + let characterFormats = formats[ i ]; + + // Set multiline tags in queue for building the tree. + if ( multilineTag ) { + if ( character === LINE_SEPARATOR ) { + characterFormats = lastSeparatorFormats = ( replacements[ i ] || [] ).reduce( ( accumulator, format ) => { + accumulator.push( format, multilineFormat ); + return accumulator; + }, [ multilineFormat ] ); + } else { + characterFormats = [ ...lastSeparatorFormats, ...( characterFormats || [] ) ]; + } + } + + let pointer = getLastChild( tree ); + + if ( shouldInsertPadding && character === LINE_SEPARATOR ) { + let node = pointer; + + while ( ! isText( node ) ) { + node = getLastChild( node ); + } + + append( getParent( node ), ZWNBSP ); + } + + // Set selection for the start of line. + if ( lastCharacter === LINE_SEPARATOR ) { + let node = pointer; + + while ( ! isText( node ) ) { + node = getLastChild( node ); + } + + if ( onStartIndex && start === i ) { + onStartIndex( tree, node ); + } + + if ( onEndIndex && end === i ) { + onEndIndex( tree, node ); + } + } + + if ( characterFormats ) { + characterFormats.forEach( ( format, formatIndex ) => { + if ( + pointer && + lastCharacterFormats && + format === lastCharacterFormats[ formatIndex ] && + // Do not reuse the last element if the character is a + // line separator. + ( character !== LINE_SEPARATOR || + characterFormats.length - 1 !== formatIndex ) + ) { + pointer = getLastChild( pointer ); + return; + } + + const { type, attributes, unregisteredAttributes } = format; + + const boundaryClass = ( + isEditableTree && + character !== LINE_SEPARATOR && + format === deepestActiveFormat + ); + + const parent = getParent( pointer ); + const newNode = append( parent, fromFormat( { + type, + attributes, + unregisteredAttributes, + boundaryClass, + isEditableTree, + } ) ); + + if ( isText( pointer ) && getText( pointer ).length === 0 ) { + remove( pointer ); + } + + pointer = append( newNode, '' ); + } ); + } + + // No need for further processing if the character is a line separator. + if ( character === LINE_SEPARATOR ) { + lastCharacterFormats = characterFormats; + lastCharacter = character; + continue; + } + + // If there is selection at 0, handle it before characters are inserted. + if ( i === 0 ) { + if ( onStartIndex && start === 0 ) { + onStartIndex( tree, pointer ); + } + + if ( onEndIndex && end === 0 ) { + onEndIndex( tree, pointer ); + } + } + + if ( character === OBJECT_REPLACEMENT_CHARACTER ) { + if ( ! isEditableTree && replacements[ i ].type === 'script' ) { + pointer = append( + getParent( pointer ), + fromFormat( { + type: 'script', + isEditableTree, + } ) + ); + append( pointer, { + html: decodeURIComponent( + replacements[ i ].attributes[ 'data-rich-text-script' ] + ), + } ); + } else { + pointer = append( + getParent( pointer ), + fromFormat( { + ...replacements[ i ], + object: true, + isEditableTree, + } ) + ); + } + // Ensure pointer is text node. + pointer = append( getParent( pointer ), '' ); + } else if ( character === '\n' ) { + pointer = append( getParent( pointer ), { + type: 'br', + attributes: isEditableTree ? { + 'data-rich-text-line-break': 'true', + } : undefined, + object: true, + } ); + // Ensure pointer is text node. + pointer = append( getParent( pointer ), '' ); + } else if ( ! isText( pointer ) ) { + pointer = append( getParent( pointer ), character ); + } else { + appendText( pointer, character ); + } + + if ( onStartIndex && start === i + 1 ) { + onStartIndex( tree, pointer ); + } + + if ( onEndIndex && end === i + 1 ) { + onEndIndex( tree, pointer ); + } + + if ( shouldInsertPadding && i === text.length ) { + append( getParent( pointer ), ZWNBSP ); + + if ( placeholder && text.length === 0 ) { + append( getParent( pointer ), { + type: 'span', + attributes: { + 'data-rich-text-placeholder': placeholder, + // Necessary to prevent the placeholder from catching + // selection. The placeholder is also not editable after + // all. + contenteditable: 'false', + }, + } ); + } + } + + lastCharacterFormats = characterFormats; + lastCharacter = character; + } + + return tree; +} diff --git a/gb-src/packages/rich-text/src/toggle-format.js b/gb-src/packages/rich-text/src/toggle-format.js new file mode 100644 index 0000000000000..7545f5d30c9d6 --- /dev/null +++ b/gb-src/packages/rich-text/src/toggle-format.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ + +import { getActiveFormat } from './get-active-format'; +import { removeFormat } from './remove-format'; +import { applyFormat } from './apply-format'; + +/** + * Toggles a format object to a Rich Text value at the current selection. + * + * @param {Object} value Value to modify. + * @param {Object} format Format to apply or remove. + * + * @return {Object} A new value with the format applied or removed. + */ +export function toggleFormat( value, format ) { + if ( getActiveFormat( value, format.type ) ) { + return removeFormat( value, format.type ); + } + + return applyFormat( value, format ); +} diff --git a/gb-src/packages/rich-text/src/unregister-format-type.js b/gb-src/packages/rich-text/src/unregister-format-type.js new file mode 100644 index 0000000000000..a70c1d1d417f2 --- /dev/null +++ b/gb-src/packages/rich-text/src/unregister-format-type.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data'; +import { removeFilter } from '@wordpress/hooks'; + +/** + * Unregisters a format. + * + * @param {string} name Format name. + * + * @return {WPFormat|undefined} The previous format value, if it has been successfully + * unregistered; otherwise `undefined`. + */ +export function unregisterFormatType( name ) { + const oldFormat = select( 'core/rich-text' ).getFormatType( name ); + + if ( ! oldFormat ) { + window.console.error( + `Format ${ name } is not registered.` + ); + return; + } + + if ( oldFormat.__experimentalCreatePrepareEditableTree ) { + removeFilter( 'experimentalRichText', name ); + } + + dispatch( 'core/rich-text' ).removeFormatTypes( name ); + + return oldFormat; +} diff --git a/gb-src/packages/rich-text/src/update-formats.js b/gb-src/packages/rich-text/src/update-formats.js new file mode 100644 index 0000000000000..bc99c9ba0e5db --- /dev/null +++ b/gb-src/packages/rich-text/src/update-formats.js @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from './is-format-equal'; + +/** + * Efficiently updates all the formats from `start` (including) until `end` + * (excluding) with the active formats. Mutates `value`. + * + * @param {Object} $1 Named paramentes. + * @param {Object} $1.value Value te update. + * @param {number} $1.start Index to update from. + * @param {number} $1.end Index to update until. + * @param {Array} $1.formats Replacement formats. + * + * @return {Object} Mutated value. + */ +export function updateFormats( { value, start, end, formats } ) { + const formatsBefore = value.formats[ start - 1 ] || []; + const formatsAfter = value.formats[ end ] || []; + + // First, fix the references. If any format right before or after are + // equal, the replacement format should use the same reference. + value.activeFormats = formats.map( ( format, index ) => { + if ( formatsBefore[ index ] ) { + if ( isFormatEqual( format, formatsBefore[ index ] ) ) { + return formatsBefore[ index ]; + } + } else if ( formatsAfter[ index ] ) { + if ( isFormatEqual( format, formatsAfter[ index ] ) ) { + return formatsAfter[ index ]; + } + } + + return format; + } ); + + while ( --end >= start ) { + if ( value.activeFormats.length > 0 ) { + value.formats[ end ] = value.activeFormats; + } else { + delete value.formats[ end ]; + } + } + + return value; +} diff --git a/gb-src/packages/scripts/.npmrc b/gb-src/packages/scripts/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/scripts/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/scripts/CHANGELOG.md b/gb-src/packages/scripts/CHANGELOG.md new file mode 100644 index 0000000000000..3d137bfb2b9fb --- /dev/null +++ b/gb-src/packages/scripts/CHANGELOG.md @@ -0,0 +1,162 @@ +## 5.1.0 + +### New Features + +- The bundled `webpack` dependency has been updated from requiring `4.8.3` to requiring `^4.41.0` ([#17746](https://github.com/WordPress/gutenberg/pull/17746)). + +### Bug Fixes + +- Added a temporary workaround for the default config used with `lint-js` command. It uses linting rules for both e2e and unit tests with all files until override files globbing logic is fixed when using `eslint` with `--config` (related [issue](https://github.com/eslint/eslint/issues/11558)). + +## 5.0.0 (2019-09-16) + +### Breaking Changes + +- The bundled `@wordpress/dependency-extraction-webpack-plugin` dependency has been updated to the next major version `^2.0.0`. `start` and `build` scripts save now the generated asset file for each entry point in the new PHP output format. + +## 4.1.0 (2019-09-03) + +### New Features + +- Add the new `env` family of scripts [(#17004](https://github.com/WordPress/gutenberg/pull/17004/)). + +### Bug Fixes + +- Add `wordpress` folder to the list of ignored paths in all applicable config files ([#17296](https://github.com/WordPress/gutenberg/pull/17296)). + +## 4.0.0 (2019-08-29) + +### Breaking Changes + +- Test files matching has changed to fix the overlap between two types of tests implemented with `test-e2e` and `test-unit`. Refer to the documentation of the corresponding scripts to learn about new file discovery rules. + +### New Features + +- The bundled `puppeteer` dependency has been updated from requiring `1.6.1` to requiring `^1.19.0` ([#16875](https://github.com/WordPress/gutenberg/pull/16875)). It uses Chromium v77 instead of Chromium v69. +- The bundled `jest-puppeteer` dependency has been updated from requiring `^4.0.0` to requiring `^4.3.0` ([#16875](https://github.com/WordPress/gutenberg/pull/16875)). +- The bundled `eslint` dependency has been updated from requiring `^5.16.0` to requiring `^6.1.0`. +- The bundled `@wordpress/eslint-plugin` dependency has been updated to the next major version `^3.0.0` due to new ESLint rules enabled for all test files. + +### Bug Fix + +- Use the SCSS shared `stylelint-config-wordpress` config so that both CSS and SCSS rules are used ([#17060](https://github.com/WordPress/gutenberg/pull/17060)) + +## 3.4.0 (2019-08-05) + +### New Features + +- The `build` and `start` commands supports simplified syntax for multiple entry points: `wp-scripts build entry-one.js entry-two.js` ([15982](https://github.com/WordPress/gutenberg/pull/15982)). + +### Bug Fix + +- Added missing [babel-jest](https://www.npmjs.com/package/babel-jest) dependency ([#16259](https://github.com/WordPress/gutenberg/pull/16259)). + +## 3.3.0 (2019-06-12) + +### New Features + +- The `lint-js` command lints now JS files in the entire project's directories by default ([15890](https://github.com/WordPress/gutenberg/pull/15890)). +- The `lint-pkg-json` command lints now `package.json` files in the entire project's directories by default ([15890](https://github.com/WordPress/gutenberg/pull/15890)). +- The `lint-style` command lints now CSS and SCSS files in the entire project's directories by default ([15890](https://github.com/WordPress/gutenberg/pull/15890)). +- The `lint-js`, `lint-pkg-json` and `lint-style` commands ignore now files located in `build` and `node_modules` folders by default ([15977](https://github.com/WordPress/gutenberg/pull/15977)). + +## 3.2.0 (2019-05-21) + +### New Features + +- Leverage `@wordpress/dependency-extraction-webpack-plugin` plugin to extract WordPress + dependencies. +- The bundled `eslint` dependency has been updated from requiring `^5.12.1` to requiring `^5.16.0`. + +### Enhancements + +- The default Webpack configuration uses [`thread-loader`](https://github.com/webpack-contrib/thread-loader) to parallelize Babel processing. +- The default Webpack configuration now opts-in to [the `cacheDirectory` option](https://webpack.js.org/loaders/babel-loader/#options) for its Babel loader. +- The `source-map-loader` is excluded from production builds. This serves only as an optimization and should otherwise have no impact on build results. + +## 3.1.0 (2019-03-20) + +## New features + +- The `build` and `start` commands will use a default webpack config if none is provided. + +## 3.0.0 (2019-03-06) + +### Breaking Changes + +- The bundled `eslint` dependency has been updated from requiring `^4.19.1` to requiring `^5.12.1` (see [Migration Guide](https://eslint.org/docs/user-guide/migrating-to-5.0.0)). +- The bundled `jest` dependency has been updated from requiring `^23.6.0` to requiring `^24.1.0` (see [Breaking Changes](https://jestjs.io/blog/2019/01/25/jest-24-refreshing-polished-typescript-friendly#breaking-changes), [#13922](https://github.com/WordPress/gutenberg/pull/13922)). +- The bundled `jest-puppeteer` dependency has been updated from requiring `3.2.1` to requiring `^4.0.0` ([#13922](https://github.com/WordPress/gutenberg/pull/13922)). + +### New Features + +- Added support for `build` script ([#12837](https://github.com/WordPress/gutenberg/pull/12837)) +- Added support for `start` script ([#12837](https://github.com/WordPress/gutenberg/pull/12837)) +- Updated `npm-package-json-lint` dependency [#14200](https://github.com/WordPress/gutenberg/pull/14200) + +### Bug Fix + +- Avoid inheriting from ESLint configurations in ancestor directories when using the default configuration ([#13483](https://github.com/WordPress/gutenberg/pull/13483)) + +## 2.5.0 (2019-01-09) + +### New Features + +- Added support for `check-engines` script ([#12721](https://github.com/WordPress/gutenberg/pull/12721)) +- Added support for `lint-style` script ([#12722](https://github.com/WordPress/gutenberg/pull/12722)) +- Added support for `test-e2e` script ([#12437](https://github.com/WordPress/gutenberg/pull/12437)) +- Update default config provided for `lint-js` script ([#12845](https://github.com/WordPress/gutenberg/pull/12845)) + +## 2.4.4 (2018-11-20) + +## 2.4.3 (2018-11-09) + +## 2.4.2 (2018-11-09) + +## 2.4.1 (2018-11-03) + +## 2.4.0 (2018-10-16) + +### New Feature + +- Added support for `lint-js` script ([#10504](https://github.com/WordPress/gutenberg/pull/10504)) + +## 2.3.0 (2018-09-30) + +### Improvements + +- New flag `--ignore` for `check-licenses` script +- Try deferring to LICENSE file for `license` fields which include filename +- Add "BSD-3-Clause-W3C" as GPL-compatible + +## 2.2.1 (2018-09-05) + +### Bug Fix + +- Resolves an issue where npm package lint script did not work in Windows environments ([#9321](https://github.com/WordPress/gutenberg/pull/9321) + +### Polish + +- Updated dependencies: `jest`, `npm-package-json-lint` and `read-pkg-up` + +## 2.0.0 (2018-07-12) + +### Breaking Change + +- Updated code to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Internal + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.2.0 (2018-05-29) + +### New Feature + +- Added support for `lint-pkg-json` script ([#128](https://github.com/WordPress/packages/pull/128)) + +## 1.1.5 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) diff --git a/gb-src/packages/scripts/README.md b/gb-src/packages/scripts/README.md new file mode 100644 index 0000000000000..ff62c86c3352d --- /dev/null +++ b/gb-src/packages/scripts/README.md @@ -0,0 +1,403 @@ +# Scripts + +This is a collection of reusable scripts tailored for WordPress development. For convenience, every tool provided in this package comes with an integrated recommended configuration. + +When working seamlessly, sophisticated command-line interfaces help to turn work with a project into a more pleasant experience. However, it’s a misleading assumption that developers can easily pick the proper tools in the first place and then ensure that they play along with each other, including all their extensions. Besides, it’s still not enough because developers are left on their own to keep all configurations and dependent tools up to date. This problem multiplies when they support more than one project which shares the same setup. + +Fortunately, there is a pattern that can simplify maintainers life – reusable scripts. The idea boils down to moving all the necessary configurations and scripts to one single tool dependency. In most cases, it should be possible to accomplish all tasks using the default settings, but some customization is allowed, too. With all that in place, updating all projects should become a very straightforward task. + +_This package is inspired by [react-scripts](https://www.npmjs.com/package/react-scripts) and [kcd-scripts](https://www.npmjs.com/package/kcd-scripts)._ + +## Installation + +You only need to install one npm module: + +```bash +npm install @wordpress/scripts --save-dev +``` + +## Setup + +This package offers a command-line interface and exposes a binary called `wp-scripts` so you can call it directly with `npx` – an npm package runner. However, this module is designed to be configured using the `scripts` section in the `package.json` file of your project. This comprehensive example demonstrates the most of the capabilities included. + +_Example:_ + +```json +{ + "scripts": { + "build": "wp-scripts build", + "check-engines": "wp-scripts check-engines", + "check-licenses": "wp-scripts check-licenses", + "lint:css": "wp-scripts lint-style", + "lint:js": "wp-scripts lint-js", + "lint:pkg-json": "wp-scripts lint-pkg-json", + "start": "wp-scripts start", + "test:e2e": "wp-scripts test-e2e", + "test:unit": "wp-scripts test-unit-js" + } +} +``` + +It might also be a good idea to get familiar with the [JavaScript Build Setup tutorial](/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md) for setting up a development environment to use ESNext syntax. It gives a very in-depth explanation of how to use the [build](#build) and [start](#start) scripts. + +## Updating to New Release + +To update an existing project to a new version of `@wordpress/scripts`, open the [changelog](/packages/scripts/CHANGELOG.md), find the version you’re currently on (check `package.json` in the top-level directory of your project), and apply the migration instructions for the newer versions. + +In most cases bumping the `@wordpress/scripts` version in `package.json` and running `npm install` in the root folder of your project should be enough, but it’s good to check the [changelog](/packages/scripts/CHANGELOG.md) for potential breaking changes. + +We commit to keeping the breaking changes minimal so you can upgrade `@wordpress/scripts` as seamless as possible. + +## Available Scripts + +### `build` + +Transforms your code according the configuration provided so it’s ready for production and optimized for the best performance. The entry point for your project’s code should be located in `src/index.js`. The output generated will be written to `build/index.js`. This script exits after producing a single build. For incremental builds, better suited for development, see the [start](#start) script. + +_Example:_ + +```json +{ + "scripts": { + "build": "wp-scripts build", + "build:custom": "wp-scripts build entry-one.js entry-two.js --output-path=custom" + } +} +``` + +This is how you execute the script with presented setup: + +* `npm run build` - builds the code for production. +* `npm run build:custom` - builds the code for production with two entry points and a custom output folder. Paths for custom entry points are relative to the project root. + +#### Advanced information + +This script uses [webpack](https://webpack.js.org/) behind the scenes. It’ll look for a webpack config in the top-level directory of your package and will use it if it finds one. If none is found, it’ll use the default config provided by `@wordpress/scripts` packages. Learn more in the [Advanced Usage](#advanced-usage) section. + +### `check-engines` + +Checks if the current `node`, `npm` (or `yarn`) versions match the given [semantic version](https://semver.org/) ranges. If the given version is not satisfied, information about installing the needed version is printed and the program exits with an error code. + +_Example:_ + +```json +{ + "scripts": { + "check-engines": "wp-scripts check-engines" + } +} +``` + +This is how you execute the script with presented setup: + +* `npm run check-engines` - checks installed version of `node` and `npm`. + +#### Advanced information + +It uses [check-node-version](https://www.npmjs.com/package/check-node-version) behind the scenes with the recommended configuration provided. You can specify your own ranges as described in [check-node-version docs](https://www.npmjs.com/package/check-node-version). Learn more in the [Advanced Usage](#advanced-usage) section. + +### `check-licenses` + +Validates that all dependencies of a project are compatible with the project’s own license. + +_Example:_ + +```json +{ + "scripts": { + "check-licenses": "wp-scripts check-licenses --prod --gpl2 --ignore=abab" + } +} +``` + +_Flags_: + +- `--prod` (or `--production`): When present, validates only `dependencies` and not `devDependencies` +- `--dev` (or `--development`): When present, validates only `devDependencies` and not `dependencies` +- `--gpl2`: Validates against [GPLv2 license compatibility](https://www.gnu.org/licenses/license-list.en.html) +- `--ignore=a,b,c`: A comma-separated set of package names to ignore for validation. This is intended to be used primarily in cases where a dependency’s `license` field is malformed. It’s assumed that any `ignored` package argument would be manually vetted for compatibility by the project owner. + +### `env` + +`env` is a family of scripts for setting up a local Docker-based development environment that plugin contributors can work in. + +To get started, it requires some configuration in your `package.json`: + +_Example:_ + +```json +{ + "scripts": { + "env": "wp-scripts env" + }, + "wp-env": { + "plugin-dir": "gutenberg", + "plugin-name": "Gutenberg", + "docker-template": "./bin/docker-compose.override.yml.template", + "welcome-logo": [ + ",⁻⁻⁻· . |", + "| ،⁓’. . |--- ,---. ,---. |---. ,---. ,---. ,---.", + "| | | | | |---' | | | | |---' | | |", + "`---' `---' `---’ `---’ ' ` `---' `---’ ` `---|", + " `---'" + ], + "welcome-build-command": "npm run dev" + } +} +``` + +In the `wp-env` config block, each entry can be configured like so: +- `plugin-dir`: Required. The name of the plugin directory within `wp-content/plugins` that this plugin will be mounted in. +- `plugin-name`: Required. The display name for this plugin. +- `docker-template`: Optional. If you need to customize the default `docker-compose.override.yml.template`, this is the path to your customized version. +- `welcome-logo`: Optional. An ASCII art logo to show after `npm run env install` finishes. +- `welcome-build-command`: Optional. The build command to show after `npm run env install` finishes, helping contributors move on to the next step. + +#### Available Sub-Scripts + +- `install`: Automatically downloads, builds, and installs a copy of WordPress to work with. This will be installed in the `wordpress` folder inside your project. You should add `wordpress` to your `.gitignore` file. +- `connect`: For contributors that have a WordPress respository already, they can define the `WP_DEVELOP_DIR` environment variable with the path to their repository, then run this command to add your plugin to it. +- `start`: Starts the Docker containers. +- `stop`: Stops the Docker containers. +- `update`: For contributors that used `npm run env install` to setup WordPress, running this command will update it to the latest checkout. +- `reinstall`: Resets the database and re-configures WordPress again. +- `cli`: Runs WP-CLI commands against the WordPress install. +- `lint-php`: Run PHPCS linting on your plugin. You will need to have `composer.json` configured to install PHPCS, with a `lint` script that runs your linting. You will also need to have an appropriately configured `phpcs.xml.dist` file. +- `test-php`: Runs your plugin's PHPUnit tests. You will need to have an appropriately configured `phpunit.xml.dist` file. +- `docker-run`: For more advanced debugging, contributors may sometimes need to run commands in the Docker containers. This is the equivalent of running `docker-compose run` within the WordPress directory. + + +### `lint-js` + +Helps enforce coding style guidelines for your JavaScript files. + +_Example:_ + +```json +{ + "scripts": { + "lint:js": "wp-scripts lint-js", + "lint:js:src": "wp-scripts lint-js ./src" + } +} +``` + +This is how you execute the script with presented setup: + +* `npm run lint:js` - lints JavaScript files in the entire project’s directories. +* `npm run lint:js:src` - lints JavaScript files in the project’s `src` subfolder’s directories. + +When you run commands similar to the `npm run lint:js:src` example above, you can provide a file, a directory, or `glob` syntax or any combination of them. See [more examples](https://eslint.org/docs/user-guide/command-line-interface). + +By default, files located in `build` and `node_modules` folders are ignored. + +#### Advanced information + +It uses [eslint](https://eslint.org/) with the set of recommended rules defined in [@wordpress/eslint-plugin](https://www.npmjs.com/package/@wordpress/eslint-plugin) npm package. You can override default rules with your own as described in [eslint docs](https://eslint.org/docs/rules/). Learn more in the [Advanced Usage](#advanced-usage) section. + +### `lint-pkg-json` + +Helps enforce standards for your `package.json` files. + +_Example:_ + +```json +{ + "scripts": { + "lint:pkg-json": "wp-scripts lint-pkg-json", + "lint:pkg-json:src": "wp-scripts lint-pkg-json ./src" + } +} +``` + +This is how you execute those scripts using the presented setup: + +* `npm run lint:pkg-json` - lints `package.json` file in the entire project’s directories. +* `npm run lint:pkg-json:src` - lints `package.json` file in the project’s `src` subfolder’s directories. + +When you run commands similar to the `npm run lint:pkg-json:src` example above, you can provide one or multiple directories to scan as well. See [more examples](https://github.com/tclindner/npm-package-json-lint/blob/HEAD/README.md#examples). + +By default, files located in `build` and `node_modules` folders are ignored. + +#### Advanced information + +It uses [npm-package-json-lint](https://www.npmjs.com/package/npm-package-json-lint) with the set of recommended rules defined in [@wordpress/npm-package-json-lint-config](https://www.npmjs.com/package/@wordpress/npm-package-json-lint-config) npm package. You can override default rules with your own as described in [npm-package-json-lint wiki](https://github.com/tclindner/npm-package-json-lint/wiki). Learn more in the [Advanced Usage](#advanced-usage) section. + +### `lint-style` + +Helps enforce coding style guidelines for your style files. + +_Example:_ + +```json +{ + "scripts": { + "lint:style": "wp-scripts lint-style", + "lint:css:src": "wp-scripts lint-style 'src/**/*.css'" + } +} +``` + +This is how you execute the script with presented setup: + +* `npm run lint:style` - lints CSS and SCSS files in the entire project’s directories. +* `npm run lint:css:src` - lints only CSS files in the project’s `src` subfolder’s directories. + +When you run commands similar to the `npm run lint:css:src` example above, be sure to include the quotation marks around file globs. This ensures that you can use the powers of [globby](https://github.com/sindresorhus/globby) (like the `**` globstar) regardless of your shell. See [more examples](https://github.com/stylelint/stylelint/blob/HEAD/docs/user-guide/cli.md#examples). + +By default, files located in `build` and `node_modules` folders are ignored. + +#### Advanced information + +It uses [stylelint](https://github.com/stylelint/stylelint) with the [stylelint-config-wordpress](https://github.com/WordPress-Coding-Standards/stylelint-config-wordpress) configuration per the [WordPress CSS Coding Standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/css/). You can override them with your own rules as described in [stylelint user guide](https://github.com/stylelint/stylelint/docs/user-guide.md). Learn more in the [Advanced Usage](#advanced-usage) section. + +### `start` + +Transforms your code according the configuration provided so it’s ready for development. The script will automatically rebuild if you make changes to the code, and you will see the build errors in the console. The entry point for your project’s code should be located in `src/index.js`. The output generated will be written to `build/index.js`. For single builds, better suited for production, see the [build](#build) script. + +_Example:_ + +```json +{ + "scripts": { + "start": "wp-scripts start", + "start:custom": "wp-scripts start entry-one.js entry-two.js --output-path=custom" + } +} +``` + +This is how you execute the script with presented setup: + +* `npm start` - starts the build for development. +* `npm run start:custom` - starts the build for development which contains two entry points and a custom output folder. Paths for custom entry points are relative to the project root. + +#### Advanced information + +This script uses [webpack](https://webpack.js.org/) behind the scenes. It’ll look for a webpack config in the top-level directory of your package and will use it if it finds one. If none is found, it’ll use the default config provided by `@wordpress/scripts` packages. Learn more in the [Advanced Usage](#advanced-usage) section. + +### `test-e2e` + +Launches the End-To-End (E2E) test runner. Writing tests can be done using the [Jest API](https://jestjs.io/docs/en/api) in combination with the [Puppeteer API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md): + +> [Jest](https://jestjs.io/) is a delightful JavaScript Testing Framework with a focus on simplicity. + +> [Puppeteer](https://pptr.dev/) is a Node library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). Puppeteer runs [headless](https://developers.google.com/web/updates/2017/04/headless-chrome) by default, but can be configured to run full (non-headless) Chrome or Chromium. + +_Example:_ + +```json +{ + "scripts": { + "test:e2e": "wp-scripts test-e2e", + "test:e2e:help": "wp-scripts test-e2e --help" + } +} +``` + +This is how you execute those scripts using the presented setup: + +* `npm run test:e2e` - runs all e2e tests. +* `npm run test:e2e:help` - prints all available options to configure e2e test runner. +* `npm run test-e2e -- --puppeteer-interactive` - runs all e2e tests interactively. +* `npm run test-e2e FILE_NAME -- --puppeteer-interactive ` - runs one test file interactively. +* `npm run test-e2e:watch -- --puppeteer-interactive` - runs all tests interactively and watch for changes. + +Jest will look for test files with any of the following popular naming conventions: + +- Files with `.js` (or `.ts`) suffix at any level of depth in `spec` folders. +- Files with `.spec.js` (or `.spec.ts`) suffix. + +This script automatically detects the best config to start Puppeteer but sometimes you may need to specify custom options: + - You can add a `jest-puppeteer.config.js` at the root of the project or define a custom path using `JEST_PUPPETEER_CONFIG` environment variable. Check [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer#jest-puppeteerconfigjs) for more details. + +We enforce that all tests run serially in the current process using [--runInBand](https://jestjs.io/docs/en/cli#runinband) Jest CLI option to avoid conflicts between tests caused by the fact that they share the same WordPress instance. + +#### Advanced information + +It uses [Jest](https://jestjs.io/) behind the scenes and you are able to use all of its [CLI options](https://jestjs.io/docs/en/cli.html). You can also run `./node_modules/.bin/wp-scripts test:e2e --help` or `npm run test:e2e:help` (as mentioned above) to view all of the available options. Learn more in the [Advanced Usage](#advanced-usage) section. + +### `test-unit-js` + +_Alias_: `test-unit-jest` + +Launches the unit test runner. Writing tests can be done using the [Jest API](https://jestjs.io/docs/en/api). + +_Example:_ + +```json +{ + "scripts": { + "test:unit": "wp-scripts test-unit-js", + "test:unit:help": "wp-scripts test-unit-js --help", + "test:unit:watch": "wp-scripts test-unit-js --watch" + } +} +``` + +This is how you execute those scripts using the presented setup: + +* `npm run test:unit` - runs all unit tests. +* `npm run test:unit:help` - prints all available options to configure unit tests runner. +* `npm run test:unit:watch` - runs all unit tests in the watch mode. + +Jest will look for test files with any of the following popular naming conventions: + +- Files with `.js` (or `.ts`) suffix located at any level of depth in `__tests__` folders. +- Files with `.js` (or `.ts`) suffix directly located in `test` folders. +- Files with `.test.js` (or `.test.ts`) suffix. + +#### Advanced information + +It uses [Jest](https://jestjs.io/) behind the scenes and you are able to use all of its [CLI options](https://jestjs.io/docs/en/cli.html). You can also run `./node_modules/.bin/wp-scripts test:unit --help` or `npm run test:unit:help` (as mentioned above) to view all of the available options. By default, it uses the set of recommended options defined in [@wordpress/jest-preset-default](https://www.npmjs.com/package/@wordpress/jest-preset-default) npm package. You can override them with your own options as described in [Jest documentation](https://jestjs.io/docs/en/configuration). Learn more in the [Advanced Usage](#advanced-usage) section. + +## Advanced Usage + +In general, this package should be used with the set of recommended config files. While it’s possible to override every single config file provided, if you have to do it, it means that your use case is far more complicated than anticipated. If that happens, it would be better to avoid using the whole abstraction layer and set up your project with full control over tooling used. + +### Webpack config + +The `build` and `start` commands use [webpack](https://webpack.js.org/) behind the scenes. webpack is a tool that helps you transform your code into something else. For example: it can take code written in ESNext and output ES5 compatible code that is minified for production. + +#### Default webpack config + +`@wordpress/scripts` bundles the default webpack config used as a base by the WordPress editor. These are the defaults: + +* [Entry](https://webpack.js.org/configuration/entry-context/#entry): `src/index.js` +* [Output](https://webpack.js.org/configuration/output): `build/index.js` +* [Plugins](https://webpack.js.org/configuration/plugins): The webpack plugin provided by +[`@wordpress/dependency-extraction-webpack-plugin`](/packages/dependency-extraction-webpack-plugin/README.md) is used +with the default configuration to ensure that WordPress provided scripts are not included in the +built bundle. + +#### Provide your own webpack config + +Should there be any situation where you want to provide your own webpack config, you can do so. The `build` and `start` commands will use your provided file when: + +* the command receives a `--config` argument. Example: `wp-scripts build --config my-own-webpack-config.js`. +* there is a file called `webpack.config.js` or `webpack.config.babel.js` in the top-level directory of your package (at the same level than your `package.json`). + +##### Extending the webpack config + +To extend the provided webpack config, or replace subsections within the provided webpack config, you can provide your own `webpack.config.js` file, `require` the provided `webpack.config.js` file, and use the [`spread` operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) to import all of or part of the provided configuration. + +In the example below, a `webpack.config.js` file is added to the root folder extending the provided webpack config to include [`@svgr/webpack`](https://www.npmjs.com/package/@svgr/webpack) and [`url-loader`](https://github.com/webpack-contrib/url-loader): + +```javascript +const defaultConfig = require("@wordpress/scripts/config/webpack.config"); + +module.exports = { + ...defaultConfig, + module: { + ...defaultConfig.module, + rules: [ + ...defaultConfig.module.rules, + { + test: /\.svg$/, + use: ["@svgr/webpack", "url-loader"] + } + ] + } +}; +``` +If you follow this approach, please, be aware that future versions of this package may change what webpack and Babel plugins we bundle, default configs, etc. Should those changes be necessary, they will be registered in the [package’s CHANGELOG](https://github.com/WordPress/gutenberg/blob/master/packages/scripts/CHANGELOG.md), so make sure to read it before upgrading. +

    Code is Poetry.

    diff --git a/gb-src/packages/scripts/bin/wp-scripts.js b/gb-src/packages/scripts/bin/wp-scripts.js new file mode 100755 index 0000000000000..a74076ead7f85 --- /dev/null +++ b/gb-src/packages/scripts/bin/wp-scripts.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +/** + * Internal dependencies + */ +const { getArgsFromCLI, spawnScript } = require( '../utils' ); + +const [ scriptName, ...nodesArgs ] = getArgsFromCLI(); + +spawnScript( scriptName, nodesArgs ); diff --git a/gb-src/packages/scripts/config/.eslintignore b/gb-src/packages/scripts/config/.eslintignore new file mode 100644 index 0000000000000..86ad1b8cdc7b8 --- /dev/null +++ b/gb-src/packages/scripts/config/.eslintignore @@ -0,0 +1,3 @@ +build +node_modules +wordpress diff --git a/gb-src/packages/scripts/config/.eslintrc.js b/gb-src/packages/scripts/config/.eslintrc.js new file mode 100644 index 0000000000000..98c2e716c390c --- /dev/null +++ b/gb-src/packages/scripts/config/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + root: true, + extends: [ + 'plugin:@wordpress/eslint-plugin/recommended', + + // Temporary workaround to use linting rules for both e2e and unit tests with all files + // until override files globbing logic is fixed when using with --config. + // @see https://github.com/eslint/eslint/issues/11558 + 'plugin:@wordpress/eslint-plugin/test-e2e', + 'plugin:@wordpress/eslint-plugin/test-unit', + ], +}; diff --git a/gb-src/packages/scripts/config/.npmpackagejsonlintignore b/gb-src/packages/scripts/config/.npmpackagejsonlintignore new file mode 100644 index 0000000000000..c551598526f04 --- /dev/null +++ b/gb-src/packages/scripts/config/.npmpackagejsonlintignore @@ -0,0 +1,4 @@ +# By default, all `node_modules` are ignored. + +build +wordpress diff --git a/gb-src/packages/scripts/config/.stylelintignore b/gb-src/packages/scripts/config/.stylelintignore new file mode 100644 index 0000000000000..c551598526f04 --- /dev/null +++ b/gb-src/packages/scripts/config/.stylelintignore @@ -0,0 +1,4 @@ +# By default, all `node_modules` are ignored. + +build +wordpress diff --git a/gb-src/packages/scripts/config/.stylelintrc.json b/gb-src/packages/scripts/config/.stylelintrc.json new file mode 100644 index 0000000000000..42bc0988349e5 --- /dev/null +++ b/gb-src/packages/scripts/config/.stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "stylelint-config-wordpress/scss" +} diff --git a/gb-src/packages/scripts/config/babel-transform.js b/gb-src/packages/scripts/config/babel-transform.js new file mode 100644 index 0000000000000..cf965157bdea5 --- /dev/null +++ b/gb-src/packages/scripts/config/babel-transform.js @@ -0,0 +1,8 @@ +/** + * External dependencies + */ +const babelJest = require( 'babel-jest' ); + +module.exports = babelJest.createTransformer( { + presets: [ '@wordpress/babel-preset-default' ], +} ); diff --git a/gb-src/packages/scripts/config/docker-compose.override.yml.template b/gb-src/packages/scripts/config/docker-compose.override.yml.template new file mode 100644 index 0000000000000..092d2df9c3568 --- /dev/null +++ b/gb-src/packages/scripts/config/docker-compose.override.yml.template @@ -0,0 +1,13 @@ +services: + wordpress-develop: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + php: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + cli: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% + phpunit: + volumes: + - %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR% diff --git a/gb-src/packages/scripts/config/jest-e2e.config.js b/gb-src/packages/scripts/config/jest-e2e.config.js new file mode 100644 index 0000000000000..df11962945463 --- /dev/null +++ b/gb-src/packages/scripts/config/jest-e2e.config.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { hasBabelConfig } = require( '../utils' ); + +const jestE2EConfig = { + preset: 'jest-puppeteer', + testMatch: [ + '**/specs/**/*.[jt]s', + '**/?(*.)spec.[jt]s', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/wordpress/', + ], +}; + +if ( ! hasBabelConfig() ) { + jestE2EConfig.transform = { + '^.+\\.[jt]sx?$': path.join( __dirname, 'babel-transform' ), + }; +} + +module.exports = jestE2EConfig; diff --git a/gb-src/packages/scripts/config/jest-unit.config.js b/gb-src/packages/scripts/config/jest-unit.config.js new file mode 100644 index 0000000000000..3244e163925d6 --- /dev/null +++ b/gb-src/packages/scripts/config/jest-unit.config.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { hasBabelConfig } = require( '../utils' ); + +const jestUnitConfig = { + preset: '@wordpress/jest-preset-default', +}; + +if ( ! hasBabelConfig() ) { + jestUnitConfig.transform = { + '^.+\\.[jt]sx?$': path.join( __dirname, 'babel-transform' ), + }; +} + +module.exports = jestUnitConfig; diff --git a/gb-src/packages/scripts/config/npmpackagejsonlint.json b/gb-src/packages/scripts/config/npmpackagejsonlint.json new file mode 100644 index 0000000000000..2d323d317d8ec --- /dev/null +++ b/gb-src/packages/scripts/config/npmpackagejsonlint.json @@ -0,0 +1,3 @@ +{ + "extends": "@wordpress/npm-package-json-lint-config" +} diff --git a/gb-src/packages/scripts/config/puppeteer.config.js b/gb-src/packages/scripts/config/puppeteer.config.js new file mode 100644 index 0000000000000..f42070351bc84 --- /dev/null +++ b/gb-src/packages/scripts/config/puppeteer.config.js @@ -0,0 +1,6 @@ +module.exports = { + launch: { + headless: process.env.PUPPETEER_HEADLESS !== 'false', + slowMo: parseInt( process.env.PUPPETEER_SLOWMO, 10 ) || 0, + }, +}; diff --git a/gb-src/packages/scripts/config/webpack.config.js b/gb-src/packages/scripts/config/webpack.config.js new file mode 100644 index 0000000000000..5a3b30163a432 --- /dev/null +++ b/gb-src/packages/scripts/config/webpack.config.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); +const LiveReloadPlugin = require( 'webpack-livereload-plugin' ); +const path = require( 'path' ); + +/** + * WordPress dependencies + */ +const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' ); + +/** + * Internal dependencies + */ +const { hasBabelConfig } = require( '../utils' ); + +const isProduction = process.env.NODE_ENV === 'production'; +const mode = isProduction ? 'production' : 'development'; + +const config = { + mode, + entry: { + index: path.resolve( process.cwd(), 'src', 'index.js' ), + }, + output: { + filename: '[name].js', + path: path.resolve( process.cwd(), 'build' ), + }, + resolve: { + alias: { + 'lodash-es': 'lodash', + }, + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: [ + require.resolve( 'thread-loader' ), + { + loader: require.resolve( 'babel-loader' ), + options: { + // Babel uses a directory within local node_modules + // by default. Use the environment variable option + // to enable more persistent caching. + cacheDirectory: process.env.BABEL_CACHE_DIRECTORY || true, + + // Provide a fallback configuration if there's not + // one explicitly available in the project. + ...( ! hasBabelConfig() && { + babelrc: false, + configFile: false, + presets: [ require.resolve( '@wordpress/babel-preset-default' ) ], + } ), + }, + }, + ], + }, + ], + }, + plugins: [ + // WP_BUNDLE_ANALYZER global variable enables utility that represents bundle content + // as convenient interactive zoomable treemap. + process.env.WP_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(), + // WP_LIVE_RELOAD_PORT global variable changes port on which live reload works + // when running watch mode. + ! isProduction && new LiveReloadPlugin( { port: process.env.WP_LIVE_RELOAD_PORT || 35729 } ), + new DependencyExtractionWebpackPlugin( { injectPolyfill: true } ), + ].filter( Boolean ), + stats: { + children: false, + }, +}; + +if ( ! isProduction ) { + // WP_DEVTOOL global variable controls how source maps are generated. + // See: https://webpack.js.org/configuration/devtool/#devtool. + config.devtool = process.env.WP_DEVTOOL || 'source-map'; + config.module.rules.unshift( { + test: /\.js$/, + use: require.resolve( 'source-map-loader' ), + enforce: 'pre', + } ); +} + +module.exports = config; diff --git a/gb-src/packages/scripts/package.json b/gb-src/packages/scripts/package.json new file mode 100644 index 0000000000000..1ddd526a71a86 --- /dev/null +++ b/gb-src/packages/scripts/package.json @@ -0,0 +1,71 @@ +{ + "name": "@wordpress/scripts", + "version": "5.1.0", + "description": "Collection of reusable scripts for WordPress development.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "scripts", + "reusable-scripts", + "wordpress-scripts" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/scripts/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/scripts" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=8" + }, + "files": [ + "bin", + "config", + "scripts", + "utils" + ], + "bin": { + "wp-scripts": "./bin/wp-scripts.js" + }, + "dependencies": { + "@wordpress/babel-preset-default": "file:../babel-preset-default", + "@wordpress/dependency-extraction-webpack-plugin": "file:../dependency-extraction-webpack-plugin", + "@wordpress/eslint-plugin": "file:../eslint-plugin", + "@wordpress/jest-preset-default": "file:../jest-preset-default", + "@wordpress/npm-package-json-lint-config": "file:../npm-package-json-lint-config", + "babel-jest": "^24.7.1", + "babel-loader": "^8.0.5", + "chalk": "^2.4.2", + "check-node-version": "^3.1.1", + "command-exists": "^1.2.8", + "cross-spawn": "^5.1.0", + "decompress-zip": "^0.2.2", + "eslint": "^6.1.0", + "jest": "^24.7.1", + "jest-puppeteer": "^4.3.0", + "js-yaml": "^3.13.1", + "lodash": "^4.17.15", + "minimist": "^1.2.0", + "npm-package-json-lint": "^3.6.0", + "puppeteer": "^1.19.0", + "read-pkg-up": "^1.0.1", + "request": "^2.88.0", + "resolve-bin": "^0.4.0", + "source-map-loader": "^0.2.4", + "sprintf-js": "^1.1.1", + "stylelint": "^9.10.1", + "stylelint-config-wordpress": "^13.1.0", + "thread-loader": "^2.1.2", + "webpack": "^4.41.0", + "webpack-bundle-analyzer": "^3.3.2", + "webpack-cli": "^3.1.2", + "webpack-livereload-plugin": "^2.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/scripts/scripts/build.js b/gb-src/packages/scripts/scripts/build.js new file mode 100644 index 0000000000000..2c3b9318afd4d --- /dev/null +++ b/gb-src/packages/scripts/scripts/build.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { getWebpackArgs } = require( '../utils' ); + +process.env.NODE_ENV = process.env.NODE_ENV || 'production'; +const { status } = spawn( + resolveBin( 'webpack' ), + getWebpackArgs(), + { stdio: 'inherit' } +); +process.exit( status ); diff --git a/gb-src/packages/scripts/scripts/check-engines.js b/gb-src/packages/scripts/scripts/check-engines.js new file mode 100644 index 0000000000000..aba446313e5a9 --- /dev/null +++ b/gb-src/packages/scripts/scripts/check-engines.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { + getArgsFromCLI, + hasArgInCLI, +} = require( '../utils' ); + +const args = getArgsFromCLI(); + +const hasConfig = hasArgInCLI( '--package' ) || + hasArgInCLI( '--node' ) || + hasArgInCLI( '--npm' ) || + hasArgInCLI( '--yarn' ); +const config = ! hasConfig ? + [ + '--node', '>=10.0.0', + '--npm', '>=6.0.0', + ] : + []; + +const result = spawn( + resolveBin( 'check-node-version' ), + [ ...config, ...args ], + { stdio: 'inherit' } +); + +process.exit( result.status ); diff --git a/gb-src/packages/scripts/scripts/check-licenses.js b/gb-src/packages/scripts/scripts/check-licenses.js new file mode 100644 index 0000000000000..1b1d46b9befbc --- /dev/null +++ b/gb-src/packages/scripts/scripts/check-licenses.js @@ -0,0 +1,277 @@ +/** + * External dependencies + */ +const spawn = require( 'cross-spawn' ); +const { existsSync, readFileSync } = require( 'fs' ); +const { sep } = require( 'path' ); +const chalk = require( 'chalk' ); + +/** + * Internal dependencies + */ +const { getArgFromCLI, hasArgInCLI } = require( '../utils' ); + +/* + * WARNING: Changes to this file may inadvertently cause us to distribute code that + * is not GPL2 compatible. + * + * When adding a new license (for example, when a new package has a variation of the + * various license strings), please ensure that changes to this file are explicitly + * reviewed and approved. + */ + +const ERROR = chalk.reset.inverse.bold.red( ' ERROR ' ); + +const prod = hasArgInCLI( '--prod' ) || hasArgInCLI( '--production' ); +const dev = hasArgInCLI( '--dev' ) || hasArgInCLI( '--development' ); +const gpl2 = hasArgInCLI( '--gpl2' ); +const ignored = hasArgInCLI( '--ignore' ) ? + getArgFromCLI( '--ignore' ) + // "--ignore=a, b" -> "[ 'a', ' b' ]" + .split( ',' ) + // "[ 'a', ' b' ]" -> "[ 'a', 'b' ]" + .map( ( moduleName ) => moduleName.trim() ) : + []; + +/* + * A list of license strings that we've found to be GPL2 compatible. + * + * Note the strings with "AND" in them at the bottom: these should only be added when + * all the licenses in that string are GPL2 compatible. + */ +const gpl2CompatibleLicenses = [ + 'Apache-2.0 WITH LLVM-exception', + 'Artistic-2.0', + 'BSD', + 'BSD-2-Clause', + 'BSD-3-Clause', + 'BSD-3-Clause-W3C', + 'BSD-like', + 'CC-BY-4.0', + 'CC0-1.0', + 'GPL-2.0', + 'GPL-2.0+', + 'GPL-2.0-or-later', + 'ISC', + 'LGPL-2.1', + 'MIT', + 'MIT/X11', + 'MIT (http://mootools.net/license.txt)', + 'MPL-2.0', + 'Public Domain', + 'Unlicense', + 'WTFPL', + 'Zlib', + '(MIT AND BSD-3-Clause)', + '(MIT AND Zlib)', + '(CC-BY-4.0 AND MIT)', +]; + +/* + * A list of OSS license strings that aren't GPL2 compatible. + * + * We're cool with using packages that are licensed under any of these if we're not + * distributing them (for example, build tools), but we can't included them in a release. + */ +const otherOssLicenses = [ + 'Apache-2.0', + 'Apache 2.0', + 'Apache License, Version 2.0', + 'Apache version 2.0', + 'CC-BY-3.0', + 'LGPL', +]; + +const licenses = [ + ...gpl2CompatibleLicenses, + ...( gpl2 ? [] : otherOssLicenses ), +]; + +/* + * Some packages don't included a license string in their package.json file, but they + * do have a license listed elsewhere. These files are checked for matching license strings. + */ +const licenseFiles = [ + 'LICENCE', + 'license', + 'LICENSE', + 'LICENSE.md', + 'LICENSE.txt', + 'LICENSE-MIT', + 'MIT-LICENSE.txt', + 'Readme.md', + 'README.md', +]; + +/* + * When searching through files for licensing information, these are the strings we look for, + * and their matching license. + */ +const licenseFileStrings = { + 'Apache-2.0': [ + 'Licensed under the Apache License, Version 2.0', + ], + BSD: [ + 'Redistributions in binary form must reproduce the above copyright notice,', + ], + 'BSD-3-Clause-W3C': [ + 'W3C 3-clause BSD License', + ], + MIT: [ + 'Permission is hereby granted, free of charge,', + '## License\n\nMIT', + '## License\n\n MIT', + ], +}; + +/** + * Check if a license string matches the given license. + * + * The license string can be a single license, or an SPDX-compatible "OR" license string. + * eg, "(MIT OR Zlib)". + * + * @param {string} allowedLicense The license that's allowed. + * @param {string} licenseType The license string to check. + * + * @return {boolean} true if the licenseType matches the allowedLicense, false if it doesn't. + */ +const checkLicense = ( allowedLicense, licenseType ) => { + if ( ! licenseType ) { + return false; + } + + // Some licenses have unusual capitalisation in them. + const formattedAllowedLicense = allowedLicense.toLowerCase(); + const formattedlicenseType = licenseType.toLowerCase(); + + if ( formattedAllowedLicense === formattedlicenseType ) { + return true; + } + + // We can skip the parsing below if there isn't an 'OR' in the license. + if ( ! licenseType.includes( 'OR' ) ) { + return false; + } + + /* + * In order to do a basic parse of SPDX-compatible "OR" license strings, we: + * - Remove surrounding brackets: "(mit or zlib)" -> "mit or zlib" + * - Split it into an array: "mit or zlib" -> [ "mit", "zlib" ] + * - Trim any remaining whitespace from each element + */ + const subLicenseTypes = formattedlicenseType + .replace( /^\(*/g, '' ) + .replace( /\)*$/, '' ) + .split( ' or ' ) + .map( ( e ) => e.trim() ); + + // We can then check our array of licenses against the allowedLicense. + return undefined !== subLicenseTypes.find( ( subLicenseType ) => checkLicense( allowedLicense, subLicenseType ) ); +}; + +/** + * Returns true if the given module path is not to be ignored for consideration + * in license validation, or false otherwise. + * + * @param {string} moduleName Module path. + * + * @return {boolean} Whether module path is not to be ignored. + */ +const isNotIgnoredModule = ( moduleName ) => ( + ! ignored.some( ( ignoredItem ) => ( + // `moduleName` is a file path to the module directory. Assume CLI arg + // is passed as basename of package (directory(s) after node_modules). + // Prefix with sep to avoid false-positives on prefixing variations. + moduleName.endsWith( sep + ignoredItem ) + ) ) +); + +// Use `npm ls` to grab a list of all the packages. +const child = spawn.sync( 'npm', [ + 'ls', + '--parseable', + ...( prod ? [ '--prod' ] : [] ), + ...( dev ? [ '--dev' ] : [] ), +] ); + +const modules = child.stdout + .toString() + .split( '\n' ) + .filter( isNotIgnoredModule ); + +modules.forEach( ( path ) => { + if ( ! path ) { + return; + } + + const filename = path + '/package.json'; + if ( ! existsSync( filename ) ) { + process.stdout.write( `Unable to locate package.json in ${ path }.` ); + process.exit( 1 ); + } + + /* + * The package.json format can be kind of weird. We allow for the following formats: + * - { license: 'MIT' } + * - { license: { type: 'MIT' } } + * - { licenses: [ 'MIT', 'Zlib' ] } + * - { licenses: [ { type: 'MIT' }, { type: 'Zlib' } ] } + */ + const packageInfo = require( filename ); + const license = packageInfo.license || + ( + packageInfo.licenses && + packageInfo.licenses + .map( ( l ) => l.type || l ) + .join( ' OR ' ) + ); + let licenseType = typeof license === 'object' ? license.type : license; + + // Check if the license we've detected is telling us to look in the license file, instead. + if ( licenseType && licenseFiles.find( ( licenseFile ) => licenseType.includes( licenseFile ) ) ) { + licenseType = undefined; + } + + /* + * If we haven't been able to detect a license in the package.json file, try reading + * it from the files defined in licenseFiles, instead. + */ + if ( licenseType === undefined ) { + licenseType = licenseFiles.reduce( ( detectedType, licenseFile ) => { + if ( detectedType ) { + return detectedType; + } + + const licensePath = path + '/' + licenseFile; + + if ( existsSync( licensePath ) ) { + const licenseText = readFileSync( licensePath ).toString(); + + // Check if the file contains any of the strings in licenseFileStrings + return Object.keys( licenseFileStrings ).reduce( ( stringDetectedType, licenseStringType ) => { + const licenseFileString = licenseFileStrings[ licenseStringType ]; + + return licenseFileString.reduce( ( currentDetectedType, fileString ) => { + if ( licenseText.includes( fileString ) ) { + return licenseStringType; + } + return currentDetectedType; + }, stringDetectedType ); + }, detectedType ); + } + return detectedType; + }, false ); + } + + if ( ! licenseType ) { + return false; + } + + // Now that we finally have a license to check, see if any of the allowed licenses match. + const allowed = licenses.find( ( allowedLicense ) => checkLicense( allowedLicense, licenseType ) ); + + if ( ! allowed ) { + process.exitCode = 1; + process.stdout.write( `${ ERROR } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n` ); + } +} ); diff --git a/gb-src/packages/scripts/scripts/env.js b/gb-src/packages/scripts/scripts/env.js new file mode 100644 index 0000000000000..000a4fca2e270 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +const chalk = require( 'chalk' ); + +/** + * Node dependencies. + */ +const { env, exit, stdout, cwd } = require( 'process' ); +const { normalize } = require( 'path' ); +const { existsSync } = require( 'fs' ); + +/** + * Internal dependencies + */ +const { + getArgsFromCLI, + spawnScript, +} = require( '../utils' ); + +const args = getArgsFromCLI(); + +if ( ! args.length ) { + const scripts = { + install: "If you don't have a check out of the WordPress repository that you want to work with, this will automatically download, configure, and connect to WordPress.", + connect: 'If you have a WordPress respository already, define the ' + chalk.magenta( 'WP_DEVELOP_DIR' ) + ' environment variable with the path to your repository, then run this command to add this plugin to it.', + start: "This will start a container that you've already installed and connected to.", + stop: "And this will stop it when you're done!", + update: 'If you used ' + chalk.blue( 'npm run env install' ) + ' to setup WordPress, run this command to update it.', + reinstall: 'Resets the database and re-configures WordPress again.', + cli: 'Run WP-CLI commands against the WordPress install.', + 'lint-php': 'Run PHPCS linting on the plugin.', + 'test-php': "Run the plugin's PHPUnit tests.", + 'docker-run': 'For more advanced debugging, you may sometimes need to run commands in the Docker containers. This is the equivalent of running ' + chalk.blue( 'docker-compose run' ) + '.', + }; + + stdout.write( chalk.white( 'Welcome to the WordPress Local Environment! There are several commands available to help you get up and running. Each of these commands should be run after ' ) ); + stdout.write( chalk.blue( 'npm run env' ) ); + stdout.write( chalk.white( '. For example, ' ) ); + stdout.write( chalk.blue( 'npm run env install' ) ); + stdout.write( chalk.white( '.\n\n' ) ); + + Object.keys( scripts ).forEach( ( script ) => { + stdout.write( chalk.green( script ) + '\n ' + chalk.white( scripts[ script ] ) + '\n\n' ); + } ); + exit( 0 ); +} + +const command = args.shift(); + +if ( ! env.WP_DEVELOP_DIR && command !== 'install' ) { + if ( existsSync( normalize( cwd() + '/wordpress/wp-config-sample.php' ) ) ) { + env.WP_DEVELOP_DIR = normalize( cwd() + '/wordpress' ); + env.MANAGED_WP = true; + } else { + stdout.write( chalk.white( 'Please ensure the WP_DEVELOP_DIR environment variable is set to your WordPress Development directory before running this script.\n\n' ) ); + stdout.write( chalk.white( "If you don't have a WordPress Development directory to use, run `npm run env install` to automatically configure one!\n" ) ); + exit( 1 ); + } +} + +spawnScript( `env/${ command }`, args ); diff --git a/gb-src/packages/scripts/scripts/env/cli.js b/gb-src/packages/scripts/scripts/env/cli.js new file mode 100644 index 0000000000000..ec1141a710965 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/cli.js @@ -0,0 +1,17 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env } = require( 'process' ); + +/** + * Internal dependencies + */ +const { getArgsFromCLI } = require( '../../utils' ); + +const args = getArgsFromCLI(); + +const localDir = env.LOCAL_DIR || 'src'; + +// Run WP-CLI with the working directory set correctly. +execSync( `npm run env:cli -- --path=/var/www/${ localDir } ` + args.join( ' ' ), { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/connect.js b/gb-src/packages/scripts/scripts/env/connect.js new file mode 100644 index 0000000000000..a18bdf5c32374 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/connect.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +const yaml = require( 'js-yaml' ); + +/** + * Node dependencies. + */ +const { existsSync, readFileSync, writeFileSync } = require( 'fs' ); +const { normalize } = require( 'path' ); +const { cwd, env, exit, stdout } = require( 'process' ); +const { execSync } = require( 'child_process' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + mergeYAMLConfigs, +} = require( '../../utils' ); + +const composeFile = normalize( `${ env.WP_DEVELOP_DIR }/docker-compose.override.yml` ); +let compose = {}; +if ( existsSync( composeFile ) ) { + try { + compose = yaml.safeLoad( readFileSync( composeFile, 'utf8' ) ); + } catch ( error ) { + stdout.write( 'There was an error loading your docker-compose.override.yml file. Please fix or delete it, and try again.\n' ); + stdout.write( error.toString() ); + exit( 1 ); + } +} + +const coreComposeFile = normalize( `${ env.WP_DEVELOP_DIR }/docker-compose.yml` ); +if ( ! existsSync( coreComposeFile ) ) { + stdout.write( "docker-compose.yml doesn't seem to exist. Are you sure WP_DEVELOP_DIR is a WordPress source directory?\n" ); + exit( 1 ); +} + +let coreCompose = {}; +try { + coreCompose = yaml.safeLoad( readFileSync( coreComposeFile, 'utf8' ) ); +} catch ( error ) { + stdout.write( 'There was an error loading your docker-compose.yml in your WordPress directory. Please revert any changes to it, and try again.\n' ); + stdout.write( error.toString() ); + exit( 1 ); +} + +const pluginMountDir = cwd(); + +const composeTemplateFile = env.npm_package_wp_env_docker_template ? normalize( cwd() + `/${ env.npm_package_wp_env_docker_template }` ) : fromConfigRoot( 'docker-compose.override.yml.template' ); + +const composeTemplate = readFileSync( composeTemplateFile, 'utf8' ) + .replace( /%PLUGIN_MOUNT_DIR%/gi, pluginMountDir ) + .replace( /%PLUGIN_INSTALL_DIR%/gi, env.npm_package_wp_env_plugin_dir ); + +let pluginCompose = {}; +try { + pluginCompose = yaml.safeLoad( composeTemplate ); +} catch ( error ) { + stdout.write( 'There was an error loading your docker-compose.override.yml.template file. Please revert any changes to it, and try again.\n' ); + stdout.write( error.toString() ); + exit( 1 ); +} + +stdout.write( 'Updating docker-compose.override.yml...\n' ); + +compose.version = coreCompose.version; + +const mergedCompose = mergeYAMLConfigs( compose, pluginCompose, pluginMountDir ); + +writeFileSync( composeFile, yaml.safeDump( mergedCompose, { lineWidth: -1 } ) ); + +stdout.write( 'Restarting the WordPress environment...\n' ); + +execSync( 'npm run env:stop', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); +execSync( 'npm run env:start', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/docker-run.js b/gb-src/packages/scripts/scripts/env/docker-run.js new file mode 100644 index 0000000000000..bde99a4dadfdc --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/docker-run.js @@ -0,0 +1,17 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env } = require( 'process' ); + +/** + * Internal dependencies + */ +const { getArgsFromCLI } = require( '../../utils' ); + +const args = getArgsFromCLI(); + +const localDir = env.LOCAL_DIR || 'src'; + +// Execute any docker-compose command passed to this script, in the WordPress Docker environment. +execSync( `docker-compose run -w /var/www/${ localDir }/wp-content/plugins/${ env.npm_package_wp_env_plugin_dir } --rm ` + args.join( ' ' ), { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/install.js b/gb-src/packages/scripts/scripts/env/install.js new file mode 100644 index 0000000000000..7eee086a05d84 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/install.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +const { sync: commandExistsSync } = require( 'command-exists' ); + +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env, exit, cwd, stdout } = require( 'process' ); +const { normalize } = require( 'path' ); +const { existsSync } = require( 'fs' ); + +/** + * Internal dependencies + */ +const { + buildWordPress, + downloadWordPressZip, +} = require( '../../utils' ); +const { hasArgInCLI } = require( '../../utils' ); + +env.WP_DEVELOP_DIR = normalize( cwd() + '/wordpress' ); + +if ( hasArgInCLI( '--fast' ) ) { + buildWordPress( true, true ); + return; +} + +if ( existsSync( normalize( cwd() + '/wordpress/wp-config-sample.php' ) ) ) { + stdout.write( 'It looks like WordPress is already installed, please delete the `wordpress` directory for a fresh install, or run `npm run env start` to start the existing environment.\n' ); + exit( 1 ); +} + +if ( commandExistsSync( 'git' ) ) { + execSync( 'git clone --depth=1 git://develop.git.wordpress.org/ wordpress', { stdio: 'inherit' } ); + buildWordPress( true, false ); +} else { + stdout.write( "Git isn't available. Switching to downloading a zip version.\n" ); + downloadWordPressZip() + .then( () => { + buildWordPress( true, false ); + } ); +} diff --git a/gb-src/packages/scripts/scripts/env/lint-php.js b/gb-src/packages/scripts/scripts/env/lint-php.js new file mode 100644 index 0000000000000..7c93519e933e9 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/lint-php.js @@ -0,0 +1,17 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env } = require( 'process' ); + +/** + * Internal dependencies + */ +const { getArgsFromCLI } = require( '../../utils' ); + +const args = getArgsFromCLI(); + +const localDir = env.LOCAL_DIR || 'src'; + +// Run PHPUnit with the working directory set correctly. +execSync( `docker-compose run --rm -w /var/www/${ localDir }/wp-content/plugins/${ env.npm_package_wp_env_plugin_dir } php composer lint ` + args.join( ' ' ), { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/reinstall.js b/gb-src/packages/scripts/scripts/env/reinstall.js new file mode 100644 index 0000000000000..a2cfc8cbadf1a --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/reinstall.js @@ -0,0 +1,10 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); + +// Reset the database. +execSync( 'npm run env cli db reset -- --yes --quiet', { stdio: 'inherit' } ); + +// Do a fast install, no need to re-run the build commands again. +execSync( 'npm run env install -- --fast', { stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/start.js b/gb-src/packages/scripts/scripts/env/start.js new file mode 100644 index 0000000000000..b563b897ba0c8 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/start.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +const { sync: commandExistsSync } = require( 'command-exists' ); + +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env, stdout } = require( 'process' ); +const { normalize } = require( 'path' ); +const { statSync } = require( 'fs' ); + +if ( env.MANAGED_WP ) { + // We're running in our managed WordPress environment, so check if we need to update. + let stat; + if ( false && commandExistsSync( 'git' ) ) { + stat = statSync( normalize( env.WP_DEVELOP_DIR + '/.git' ) ); + } else { + stat = statSync( normalize( env.WP_DEVELOP_DIR + '/wp-config-sample.php' ) ); + } + + const lastUpdated = new Date( stat.mtimeMs ); + if ( Date.now() - lastUpdated.getTime() > 7 * 24 * 60 * 60 * 1000 ) { + stdout.write( "\nIt's been a while since you updated WordPress. Run `npm run env update` to update it.\n\n" ); + } +} + +// Start the environment in the WordPress directory. +execSync( 'npm run env:start', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/stop.js b/gb-src/packages/scripts/scripts/env/stop.js new file mode 100644 index 0000000000000..f0218c606411d --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/stop.js @@ -0,0 +1,8 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env } = require( 'process' ); + +// Stop the environment in the WordPress directory. +execSync( 'npm run env:stop', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/test-php.js b/gb-src/packages/scripts/scripts/env/test-php.js new file mode 100644 index 0000000000000..da3529bd7ec6b --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/test-php.js @@ -0,0 +1,17 @@ +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env } = require( 'process' ); + +/** + * Internal dependencies + */ +const { getArgsFromCLI } = require( '../../utils' ); + +const args = getArgsFromCLI(); + +const localDir = env.LOCAL_DIR || 'src'; + +// Run PHPUnit with the working directory set correctly. +execSync( `npm run test:php -- -c /var/www/${ localDir }/wp-content/plugins/${ env.npm_package_wp_env_plugin_dir }/phpunit.xml.dist ` + args.join( ' ' ), { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); diff --git a/gb-src/packages/scripts/scripts/env/update.js b/gb-src/packages/scripts/scripts/env/update.js new file mode 100644 index 0000000000000..f912992f0b115 --- /dev/null +++ b/gb-src/packages/scripts/scripts/env/update.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +const { sync: commandExistsSync } = require( 'command-exists' ); + +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env, cwd, stdout } = require( 'process' ); +const { normalize } = require( 'path' ); + +/** + * Internal dependencies + */ +const { + buildWordPress, + downloadWordPressZip, +} = require( '../../utils' ); + +env.WP_DEVELOP_DIR = normalize( cwd() + '/wordpress' ); + +if ( commandExistsSync( 'git' ) ) { + execSync( 'git pull', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + buildWordPress( false, false ); +} else { + stdout.write( "Git isn't available. Switching to downloading a zip version.\n" ); + downloadWordPressZip() + .then( () => { + buildWordPress( false, false ); + } ); +} diff --git a/gb-src/packages/scripts/scripts/lint-js.js b/gb-src/packages/scripts/scripts/lint-js.js new file mode 100644 index 0000000000000..dc55a358c3e15 --- /dev/null +++ b/gb-src/packages/scripts/scripts/lint-js.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + getArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + hasPackageProp, + hasProjectFile, +} = require( '../utils' ); + +const args = getArgsFromCLI(); + +const defaultFilesArgs = hasFileArgInCLI() ? [] : [ '.' ]; + +// See: https://eslint.org/docs/user-guide/configuring#using-configuration-files-1. +const hasLintConfig = hasArgInCLI( '-c' ) || + hasArgInCLI( '--config' ) || + hasProjectFile( '.eslintrc.js' ) || + hasProjectFile( '.eslintrc.yaml' ) || + hasProjectFile( '.eslintrc.yml' ) || + hasProjectFile( '.eslintrc.json' ) || + hasProjectFile( '.eslintrc' ) || + hasPackageProp( 'eslintConfig' ); + +// When a configuration is not provided by the project, use from the default +// provided with the scripts module. Instruct ESLint to avoid discovering via +// the `--no-eslintrc` flag, as otherwise it will still merge with inherited. +const defaultConfigArgs = ! hasLintConfig ? + [ '--no-eslintrc', '--config', fromConfigRoot( '.eslintrc.js' ) ] : + []; + +// See: https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories. +const hasIgnoredFiles = hasArgInCLI( '--ignore-path' ) || + hasProjectFile( '.eslintignore' ); + +const defaultIgnoreArgs = ! hasIgnoredFiles ? + [ '--ignore-path', fromConfigRoot( '.eslintignore' ) ] : + []; + +const result = spawn( + resolveBin( 'eslint' ), + [ ...defaultConfigArgs, ...defaultIgnoreArgs, ...args, ...defaultFilesArgs ], + { stdio: 'inherit' } +); + +process.exit( result.status ); diff --git a/gb-src/packages/scripts/scripts/lint-pkg-json.js b/gb-src/packages/scripts/scripts/lint-pkg-json.js new file mode 100644 index 0000000000000..487a8191067db --- /dev/null +++ b/gb-src/packages/scripts/scripts/lint-pkg-json.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + getArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + hasProjectFile, + hasPackageProp, +} = require( '../utils' ); + +const args = getArgsFromCLI(); + +const defaultFilesArgs = hasFileArgInCLI() ? [] : [ '.' ]; + +// See: https://github.com/tclindner/npm-package-json-lint/wiki/configuration#configuration. +const hasLintConfig = hasArgInCLI( '-c' ) || + hasArgInCLI( '--configFile' ) || + hasProjectFile( '.npmpackagejsonlintrc.json' ) || + hasProjectFile( 'npmpackagejsonlint.config.js' ) || + hasPackageProp( 'npmPackageJsonLintConfig' ); + +const defaultConfigArgs = ! hasLintConfig ? + [ '--configFile', fromConfigRoot( 'npmpackagejsonlint.json' ) ] : + []; + +// See: https://github.com/tclindner/npm-package-json-lint/#cli-commands-and-configuration. +const hasIgnoredFiles = hasArgInCLI( '--ignorePath' ) || + hasProjectFile( '.npmpackagejsonlintignore' ); + +const defaultIgnoreArgs = ! hasIgnoredFiles ? + [ '--ignorePath', fromConfigRoot( '.npmpackagejsonlintignore' ) ] : + []; + +const result = spawn( + resolveBin( 'npm-package-json-lint', { executable: 'npmPkgJsonLint' } ), + [ ...defaultConfigArgs, ...defaultIgnoreArgs, ...args, ...defaultFilesArgs ], + { stdio: 'inherit' } +); + +process.exit( result.status ); diff --git a/gb-src/packages/scripts/scripts/lint-style.js b/gb-src/packages/scripts/scripts/lint-style.js new file mode 100644 index 0000000000000..3f7e86fc43277 --- /dev/null +++ b/gb-src/packages/scripts/scripts/lint-style.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + getArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + hasProjectFile, + hasPackageProp, +} = require( '../utils' ); + +const args = getArgsFromCLI(); + +const defaultFilesArgs = hasFileArgInCLI() ? [] : [ '**/*.{css,scss}' ]; + +// See: https://github.com/stylelint/stylelint/blob/master/docs/user-guide/configuration.md#loading-the-configuration-object. +const hasLintConfig = hasArgInCLI( '--config' ) || + hasProjectFile( '.stylelintrc' ) || + hasProjectFile( '.stylelintrc.js' ) || + hasProjectFile( '.stylelintrc.json' ) || + hasProjectFile( '.stylelintrc.yaml' ) || + hasProjectFile( '.stylelintrc.yml' ) || + hasProjectFile( '.stylelint.config.js' ) || + hasPackageProp( 'stylelint' ); + +const defaultConfigArgs = ! hasLintConfig ? + [ '--config', fromConfigRoot( '.stylelintrc.json' ) ] : + []; + +// See: https://github.com/stylelint/stylelint/blob/master/docs/user-guide/configuration.md#stylelintignore. +const hasIgnoredFiles = hasArgInCLI( '--ignore-path' ) || + hasProjectFile( '.stylelintignore' ); + +const defaultIgnoreArgs = ! hasIgnoredFiles ? + [ '--ignore-path', fromConfigRoot( '.stylelintignore' ) ] : + []; + +const result = spawn( + resolveBin( 'stylelint' ), + [ ...defaultConfigArgs, ...defaultIgnoreArgs, ...args, ...defaultFilesArgs ], + { stdio: 'inherit' } +); + +process.exit( result.status ); diff --git a/gb-src/packages/scripts/scripts/start.js b/gb-src/packages/scripts/scripts/start.js new file mode 100644 index 0000000000000..d27fa3a5b0ec4 --- /dev/null +++ b/gb-src/packages/scripts/scripts/start.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +const { sync: spawn } = require( 'cross-spawn' ); +const { sync: resolveBin } = require( 'resolve-bin' ); + +/** + * Internal dependencies + */ +const { getWebpackArgs } = require( '../utils' ); + +const { status } = spawn( + resolveBin( 'webpack' ), + getWebpackArgs( [ '--watch' ] ), + { stdio: 'inherit' } +); +process.exit( status ); diff --git a/gb-src/packages/scripts/scripts/test-e2e.js b/gb-src/packages/scripts/scripts/test-e2e.js new file mode 100644 index 0000000000000..c82a92f442f88 --- /dev/null +++ b/gb-src/packages/scripts/scripts/test-e2e.js @@ -0,0 +1,66 @@ +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on( 'unhandledRejection', ( err ) => { + throw err; +} ); + +/** + * External dependencies + */ +/* eslint-disable-next-line jest/no-jest-import */ +const jest = require( 'jest' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + getArgFromCLI, + getArgsFromCLI, + hasArgInCLI, + hasProjectFile, + hasJestConfig, +} = require( '../utils' ); + +// Provides a default config path for Puppeteer when jest-puppeteer.config.js +// wasn't found at the root of the project or a custom path wasn't defined +// using JEST_PUPPETEER_CONFIG environment variable. +if ( ! hasProjectFile( 'jest-puppeteer.config.js' ) && ! process.env.JEST_PUPPETEER_CONFIG ) { + process.env.JEST_PUPPETEER_CONFIG = fromConfigRoot( 'puppeteer.config.js' ); +} + +const config = ! hasJestConfig() ? + [ '--config', JSON.stringify( require( fromConfigRoot( 'jest-e2e.config.js' ) ) ) ] : + []; + +const hasRunInBand = hasArgInCLI( '--runInBand' ) || + hasArgInCLI( '-i' ); +const runInBand = ! hasRunInBand ? + [ '--runInBand' ] : + []; + +if ( hasArgInCLI( '--puppeteer-interactive' ) ) { + process.env.PUPPETEER_HEADLESS = 'false'; + process.env.PUPPETEER_SLOWMO = getArgFromCLI( '--puppeteer-slowmo' ) || 80; +} + +const configsMapping = { + WP_BASE_URL: '--wordpress-base-url', + WP_USERNAME: '--wordpress-username', + WP_PASSWORD: '--wordpress-password', +}; + +Object.entries( configsMapping ).forEach( ( [ envKey, argName ] ) => { + if ( hasArgInCLI( argName ) ) { + process.env[ envKey ] = getArgFromCLI( argName ); + } +} ); + +const cleanUpPrefixes = [ '--puppeteer-', '--wordpress-' ]; + +jest.run( [ ...config, ...runInBand, ...getArgsFromCLI( cleanUpPrefixes ) ] ); diff --git a/gb-src/packages/scripts/scripts/test-unit-jest.js b/gb-src/packages/scripts/scripts/test-unit-jest.js new file mode 100644 index 0000000000000..706addf5d6f7c --- /dev/null +++ b/gb-src/packages/scripts/scripts/test-unit-jest.js @@ -0,0 +1,31 @@ +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on( 'unhandledRejection', ( err ) => { + throw err; +} ); + +/** + * External dependencies + */ +/* eslint-disable-next-line jest/no-jest-import */ +const jest = require( 'jest' ); + +/** + * Internal dependencies + */ +const { + fromConfigRoot, + getArgsFromCLI, + hasJestConfig, +} = require( '../utils' ); + +const config = ! hasJestConfig() ? + [ '--config', JSON.stringify( require( fromConfigRoot( 'jest-unit.config.js' ) ) ) ] : + []; + +jest.run( [ ...config, ...getArgsFromCLI() ] ); diff --git a/gb-src/packages/scripts/scripts/test-unit-js.js b/gb-src/packages/scripts/scripts/test-unit-js.js new file mode 100644 index 0000000000000..04cde53fd24b6 --- /dev/null +++ b/gb-src/packages/scripts/scripts/test-unit-js.js @@ -0,0 +1 @@ +require( './test-unit-jest' ); diff --git a/gb-src/packages/scripts/utils/cli.js b/gb-src/packages/scripts/utils/cli.js new file mode 100644 index 0000000000000..9b3bbd0dd1ca3 --- /dev/null +++ b/gb-src/packages/scripts/utils/cli.js @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +const minimist = require( 'minimist' ); +const spawn = require( 'cross-spawn' ); + +/** + * Internal dependencies + */ +const { + fromScriptsRoot, + hasScriptFile, +} = require( './file' ); +const { + exit, + getArgsFromCLI, +} = require( './process' ); + +const getArgFromCLI = ( arg ) => { + for ( const cliArg of getArgsFromCLI() ) { + const [ name, value ] = cliArg.split( '=' ); + if ( name === arg ) { + return value || null; + } + } +}; + +const hasArgInCLI = ( arg ) => getArgFromCLI( arg ) !== undefined; + +const getFileArgsFromCLI = () => minimist( getArgsFromCLI() )._; + +const hasFileArgInCLI = () => getFileArgsFromCLI().length > 0; + +const handleSignal = ( signal ) => { + if ( signal === 'SIGKILL' ) { + // eslint-disable-next-line no-console + console.log( + 'The script failed because the process exited too early. ' + + 'This probably means the system ran out of memory or someone called ' + + '`kill -9` on the process.' + ); + } else if ( signal === 'SIGTERM' ) { + // eslint-disable-next-line no-console + console.log( + 'The script failed because the process exited too early. ' + + 'Someone might have called `kill` or `killall`, or the system could ' + + 'be shutting down.' + ); + } + exit( 1 ); +}; + +const spawnScript = ( scriptName, args = [] ) => { + if ( ! scriptName ) { + // eslint-disable-next-line no-console + console.log( 'Script name is missing.' ); + exit( 1 ); + } + + if ( ! hasScriptFile( scriptName ) ) { + // eslint-disable-next-line no-console + console.log( + 'Unknown script "' + scriptName + '". ' + + 'Perhaps you need to update @wordpress/scripts?' + ); + exit( 1 ); + } + + const { signal, status } = spawn.sync( + 'node', + [ + fromScriptsRoot( scriptName ), + ...args, + ], + { stdio: 'inherit' }, + ); + + if ( signal ) { + handleSignal( signal ); + } + + exit( status ); +}; + +module.exports = { + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + spawnScript, +}; diff --git a/gb-src/packages/scripts/utils/config.js b/gb-src/packages/scripts/utils/config.js new file mode 100644 index 0000000000000..a34895230af70 --- /dev/null +++ b/gb-src/packages/scripts/utils/config.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +const { basename } = require( 'path' ); + +/** + * Internal dependencies + */ +const { getArgsFromCLI, getFileArgsFromCLI, hasArgInCLI, hasFileArgInCLI } = require( './cli' ); +const { fromConfigRoot, hasProjectFile } = require( './file' ); +const { hasPackageProp } = require( './package' ); + +const hasBabelConfig = () => + hasProjectFile( '.babelrc' ) || + hasProjectFile( '.babelrc.js' ) || + hasProjectFile( 'babel.config.js' ) || + hasPackageProp( 'babel' ); + +const hasJestConfig = () => + hasArgInCLI( '-c' ) || + hasArgInCLI( '--config' ) || + hasProjectFile( 'jest.config.js' ) || + hasProjectFile( 'jest.config.json' ) || + hasPackageProp( 'jest' ); + +const hasWebpackConfig = () => hasArgInCLI( '--config' ) || + hasProjectFile( 'webpack.config.js' ) || + hasProjectFile( 'webpack.config.babel.js' ); + +/** + * Converts CLI arguments to the format which webpack understands. + * It allows to optionally pass some additional webpack CLI arguments. + * + * @see https://webpack.js.org/api/cli/#usage-with-config-file + * + * @param {?Array} additionalArgs The list of additional CLI arguments. + * + * @return {Array} The list of CLI arguments to pass to webpack CLI. + */ +const getWebpackArgs = ( additionalArgs = [] ) => { + let webpackArgs = getArgsFromCLI(); + + const hasWebpackOutputOption = hasArgInCLI( '-o' ) || hasArgInCLI( '--output' ); + if ( hasFileArgInCLI() && ! hasWebpackOutputOption ) { + /** + * Converts a path to the entry format supported by webpack, e.g.: + * `./entry-one.js` -> `entry-one=./entry-one.js` + * `entry-two.js` -> `entry-two=./entry-two.js` + * + * @param {string} path The path provided. + * + * @return {string} The entry format supported by webpack. + */ + const pathToEntry = ( path ) => { + const entry = basename( path, '.js' ); + + if ( ! path.startsWith( './' ) ) { + path = './' + path; + } + + return [ entry, path ].join( '=' ); + }; + + // The following handles the support for multiple entry points in webpack, e.g.: + // `wp-scripts build one.js custom=./two.js` -> `webpack one=./one.js custom=./two.js` + webpackArgs = webpackArgs.map( ( cliArg ) => { + if ( getFileArgsFromCLI().includes( cliArg ) && ! cliArg.includes( '=' ) ) { + return pathToEntry( cliArg ); + } + + return cliArg; + } ); + } + + if ( ! hasWebpackConfig() ) { + webpackArgs.push( '--config', fromConfigRoot( 'webpack.config.js' ) ); + } + + webpackArgs.push( ...additionalArgs ); + + return webpackArgs; +}; + +module.exports = { + getWebpackArgs, + hasBabelConfig, + hasJestConfig, +}; diff --git a/gb-src/packages/scripts/utils/env.js b/gb-src/packages/scripts/utils/env.js new file mode 100644 index 0000000000000..7b2f6d46d6ec8 --- /dev/null +++ b/gb-src/packages/scripts/utils/env.js @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +const { isPlainObject } = require( 'lodash' ); +const request = require( 'request' ); +const DecompressZip = require( 'decompress-zip' ); +const chalk = require( 'chalk' ); +const { sprintf } = require( 'sprintf-js' ); + +/** + * Node dependencies. + */ +const { execSync } = require( 'child_process' ); +const { env, exit, stdout } = require( 'process' ); +const { normalize } = require( 'path' ); +const { createWriteStream } = require( 'fs' ); +const { tmpdir } = require( 'os' ); + +/** + * Merges two YAML configs together. + * + * All new data from newConfig will be added to originalConfig. When arrays in newConfig look like lists of volume + * mount instructions, it will attempt to replace items that mount from the same location. This allows the config + * to be safely updated, and it'll be reflected in the updated config. + * + * @param {Object} originalConfig The original config object that we're overwriting. + * @param {Object} newConfig A new config object to merge into originalConfig. + * @param {string} baseDir The base directory of the plugin, volumes in this directory belong to the plugin. + * @return {Object} The merged config object. + */ +function mergeYAMLConfigs( originalConfig, newConfig, baseDir ) { + // Loop through each element of newConfig, and test what should be done with them. + Object.keys( newConfig ).forEach( ( key ) => { + if ( ! originalConfig[ key ] ) { + // If the originalConfig object doesn't have this element, we can just add it. + originalConfig[ key ] = newConfig[ key ]; + } else if ( Array.isArray( newConfig[ key ] ) ) { + // If the newConfig element is an array, we need to try and merge them. + // This is intended to merge Docker volume configs, which exist in the form: + // /path/to/local/dir:/path/to/container/dir:config:stuff + + // Build an array from the original config, with items that belong to this plugin removed. + const cleanOriginal = originalConfig[ key ].filter( ( element ) => { + return ! element.startsWith( baseDir ); + } ); + + // Append the newConfig to the remaining config. + originalConfig[ key ] = [ ...cleanOriginal, ...newConfig[ key ] ]; + } else if ( isPlainObject( newConfig[ key ] ) ) { + // If the newConfig element is an object, we need to recursively merge it. + originalConfig[ key ] = mergeYAMLConfigs( originalConfig[ key ], newConfig[ key ], baseDir ); + } else { + // Any other data types are overwritten by the newConfig. + originalConfig[ key ] = newConfig[ key ]; + } + } ); + + return originalConfig; +} + +/** + * Downloads and extracts WordPress from the GitHub repo zip file. + * + * @return {Promise} A promise that resolves when WordPress has been downloaded and extracted. + */ +function downloadWordPressZip() { + return new Promise( ( resolve ) => { + const tmpZip = normalize( tmpdir() + '/wordpress-develop.zip' ); + const tmpZipWriter = createWriteStream( tmpZip ); + + // Set up the unzipper to unzip the archive when it finishes downloading. + tmpZipWriter.on( 'finish', () => { + const unzipper = new DecompressZip( tmpZip ); + + unzipper.on( 'error', ( error ) => { + stdout.write( "ERROR: The zip file couldn't be unzipped.\n" ); + stdout.write( error.toString() ); + exit( 1 ); + } ); + + unzipper.on( 'extract', resolve ); + + stdout.write( 'Extracting...\n' ); + + unzipper.extract( { + path: env.WP_DEVELOP_DIR, + strip: 1, + filter: ( file ) => file.type !== 'Directory', + } ); + } ); + + stdout.write( 'Downloading...\n' ); + // Download the archive. + request + .get( 'https://github.com/WordPress/wordpress-develop/archive/master.zip' ) + .on( 'error', ( error ) => { + stdout.write( "ERROR: The zip file couldn't be downloaded.\n" ); + stdout.write( error.toString() ); + exit( 1 ); + } ) + .pipe( tmpZipWriter ); + } ); +} + +/** + * Runs the appropriate build/install commands in the WordPress directory. + * + * @param {boolean} newInstall Flag whether to treat this as a new WordPress install or not. + * @param {boolean} fastInstall When set, assumes NPM dependencies are already downloaded, and build commands have been run. + */ +function buildWordPress( newInstall, fastInstall ) { + if ( ! fastInstall ) { + execSync( 'npm install', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + execSync( 'npm run env:start', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + if ( env.LOCAL_DIR === 'build' ) { + execSync( 'npm run build', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + } else { + execSync( 'npm run build:dev', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + } + } + + if ( newInstall ) { + execSync( 'npm run env:install', { cwd: env.WP_DEVELOP_DIR, stdio: 'inherit' } ); + } + + // Mount the plugin into the WordPress install. + execSync( 'npm run env connect', { stdio: 'inherit' } ); + + if ( newInstall ) { + execSync( `npm run env cli plugin activate ${ env.npm_package_wp_env_plugin_dir }`, { stdio: 'inherit' } ); + + const currentUrl = execSync( 'npm run --silent env cli option get siteurl' ).toString().trim(); + + stdout.write( chalk.white( '\nWelcome to...\n' ) ); + for ( let ii = 0; env[ `npm_package_wp_env_welcome_logo_${ ii }` ]; ii++ ) { + stdout.write( chalk.green( env[ `npm_package_wp_env_welcome_logo_${ ii }` ] ) + '\n' ); + } + + if ( env.npm_package_wp_env_welcome_build_command ) { + const nextStep = sprintf( + '\nRun %s to build the latest version of %s, then open %s to get started!\n', + chalk.blue( env.npm_package_wp_env_welcome_build_command ), + chalk.green( env.npm_package_wp_env_plugin_name ), + chalk.blue( currentUrl ) + ); + stdout.write( chalk.white( nextStep ) ); + } + + stdout.write( chalk.white( '\nAccess the above install using the following credentials:\n' ) ); + + const access = sprintf( + 'Default username: %s, password: %s\n', + chalk.blue( 'admin' ), + chalk.blue( 'password' ) + ); + + stdout.write( chalk.white( access ) ); + } +} + +module.exports = { + buildWordPress, + downloadWordPressZip, + mergeYAMLConfigs, +}; diff --git a/gb-src/packages/scripts/utils/file.js b/gb-src/packages/scripts/utils/file.js new file mode 100644 index 0000000000000..7f66c5e0ade78 --- /dev/null +++ b/gb-src/packages/scripts/utils/file.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +const { existsSync } = require( 'fs' ); +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { getPackagePath } = require( './package' ); + +const fromProjectRoot = ( fileName ) => + path.join( path.dirname( getPackagePath() ), fileName ); + +const hasProjectFile = ( fileName ) => + existsSync( fromProjectRoot( fileName ) ); + +const fromConfigRoot = ( fileName ) => + path.join( path.dirname( __dirname ), 'config', fileName ); + +const fromScriptsRoot = ( scriptName ) => + path.join( path.dirname( __dirname ), 'scripts', `${ scriptName }.js` ); + +const hasScriptFile = ( scriptName ) => + existsSync( fromScriptsRoot( scriptName ) ); + +module.exports = { + fromConfigRoot, + fromScriptsRoot, + hasProjectFile, + hasScriptFile, +}; diff --git a/gb-src/packages/scripts/utils/index.js b/gb-src/packages/scripts/utils/index.js new file mode 100644 index 0000000000000..3d5e3bd7ac438 --- /dev/null +++ b/gb-src/packages/scripts/utils/index.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +const { + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + spawnScript, +} = require( './cli' ); +const { + getWebpackArgs, + hasBabelConfig, + hasJestConfig, +} = require( './config' ); +const { + buildWordPress, + downloadWordPressZip, + mergeYAMLConfigs, +} = require( './env' ); +const { + fromConfigRoot, + hasProjectFile, +} = require( './file' ); +const { + hasPackageProp, +} = require( './package' ); +const { + camelCaseDash, +} = require( './string' ); + +module.exports = { + buildWordPress, + camelCaseDash, + fromConfigRoot, + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + getWebpackArgs, + hasBabelConfig, + hasArgInCLI, + hasFileArgInCLI, + hasJestConfig, + hasPackageProp, + hasProjectFile, + downloadWordPressZip, + mergeYAMLConfigs, + spawnScript, +}; diff --git a/gb-src/packages/scripts/utils/package.js b/gb-src/packages/scripts/utils/package.js new file mode 100644 index 0000000000000..4de586f297c18 --- /dev/null +++ b/gb-src/packages/scripts/utils/package.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +const { realpathSync } = require( 'fs' ); +const { sync: readPkgUp } = require( 'read-pkg-up' ); + +/** + * Internal dependencies + */ +const { getCurrentWorkingDirectory } = require( './process' ); + +const { pkg, path: pkgPath } = readPkgUp( { + cwd: realpathSync( getCurrentWorkingDirectory() ), +} ); + +const getPackagePath = () => pkgPath; + +const hasPackageProp = ( prop ) => pkg && pkg.hasOwnProperty( prop ); + +module.exports = { + getPackagePath, + hasPackageProp, +}; diff --git a/gb-src/packages/scripts/utils/process.js b/gb-src/packages/scripts/utils/process.js new file mode 100644 index 0000000000000..4996190d21e53 --- /dev/null +++ b/gb-src/packages/scripts/utils/process.js @@ -0,0 +1,15 @@ +const getArgsFromCLI = ( excludePrefixes ) => { + const args = process.argv.slice( 2 ); + if ( excludePrefixes ) { + return args.filter( ( arg ) => { + return ! excludePrefixes.some( ( prefix ) => arg.startsWith( prefix ) ); + } ); + } + return args; +}; + +module.exports = { + exit: process.exit, + getArgsFromCLI, + getCurrentWorkingDirectory: process.cwd, +}; diff --git a/gb-src/packages/scripts/utils/string.js b/gb-src/packages/scripts/utils/string.js new file mode 100644 index 0000000000000..ea6ea3889c1ad --- /dev/null +++ b/gb-src/packages/scripts/utils/string.js @@ -0,0 +1,20 @@ +/** + * Given a string, returns a new string with dash separators converted to + * camelCase equivalent. This is not as aggressive as `_.camelCase` in + * converting to uppercase, where Lodash will also capitalize letters + * following numbers. + * + * @param {string} string Input dash-delimited string. + * + * @return {string} Camel-cased string. + */ +function camelCaseDash( string ) { + return string.replace( + /-([a-z])/g, + ( match, letter ) => letter.toUpperCase() + ); +} + +module.exports = { + camelCaseDash, +}; diff --git a/gb-src/packages/scripts/utils/test/index.js b/gb-src/packages/scripts/utils/test/index.js new file mode 100644 index 0000000000000..778c51dedb6e9 --- /dev/null +++ b/gb-src/packages/scripts/utils/test/index.js @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import crossSpawn from 'cross-spawn'; + +/** + * Internal dependencies + */ +import { + hasArgInCLI, + hasProjectFile, + spawnScript, +} from '../'; +import { + getPackagePath as getPackagePathMock, +} from '../package'; +import { + exit as exitMock, + getArgsFromCLI as getArgsFromCLIMock, +} from '../process'; + +jest.mock( '../package', () => { + const module = require.requireActual( '../package' ); + + jest.spyOn( module, 'getPackagePath' ); + + return module; +} ); +jest.mock( '../process', () => { + const module = require.requireActual( '../process' ); + + jest.spyOn( module, 'exit' ); + jest.spyOn( module, 'getArgsFromCLI' ); + + return module; +} ); + +describe( 'utils', () => { + const crossSpawnMock = jest.spyOn( crossSpawn, 'sync' ); + + describe( 'hasArgInCLI', () => { + beforeAll( () => { + getArgsFromCLIMock.mockReturnValue( [ '-a', '--b', '--config=test' ] ); + } ); + + afterAll( () => { + getArgsFromCLIMock.mockReset(); + } ); + + test( 'should return false when no args passed', () => { + getArgsFromCLIMock.mockReturnValueOnce( [] ); + + expect( hasArgInCLI( '--no-args' ) ).toBe( false ); + } ); + + test( 'should return false when checking for unrecognized arg', () => { + expect( hasArgInCLI( '--non-existent' ) ).toBe( false ); + } ); + + test( 'should return true when CLI arg found', () => { + expect( hasArgInCLI( '-a' ) ).toBe( true ); + expect( hasArgInCLI( '--b' ) ).toBe( true ); + expect( hasArgInCLI( '--config' ) ).toBe( true ); + } ); + } ); + + describe( 'hasProjectFile', () => { + test( 'should return false for the current directory and unknown file', () => { + getPackagePathMock.mockReturnValueOnce( __dirname ); + + expect( hasProjectFile( 'unknown-file.name' ) ).toBe( false ); + } ); + + test( 'should return true for the current directory and this file', () => { + getPackagePathMock.mockReturnValueOnce( __dirname ); + + expect( hasProjectFile( 'index.js' ) ).toBe( true ); + } ); + } ); + + describe( 'spawnScript', () => { + const scriptName = 'test-unit-js'; + + beforeAll( () => { + exitMock.mockImplementation( ( code ) => { + throw new Error( `Exit code: ${ code }.` ); + } ); + } ); + + afterAll( () => { + exitMock.mockReset(); + } ); + + test( 'should exit when no script name provided', () => { + expect( () => spawnScript() ).toThrow( 'Exit code: 1.' ); + expect( console ).toHaveLoggedWith( 'Script name is missing.' ); + } ); + + test( 'should exit when an unknown script name provided', () => { + expect( () => spawnScript( 'unknown-script' ) ).toThrow( 'Exit code: 1.' ); + expect( console ).toHaveLoggedWith( + 'Unknown script "unknown-script". Perhaps you need to update @wordpress/scripts?' + ); + } ); + + test( 'should exit when the script failed because of SIGKILL signal', () => { + crossSpawnMock.mockReturnValueOnce( { signal: 'SIGKILL' } ); + + expect( () => spawnScript( scriptName ) ).toThrow( 'Exit code: 1.' ); + expect( console ).toHaveLogged(); + } ); + + test( 'should exit when the script failed because of SIGTERM signal', () => { + crossSpawnMock.mockReturnValueOnce( { signal: 'SIGTERM' } ); + + expect( () => spawnScript( scriptName ) ).toThrow( 'Exit code: 1.' ); + expect( console ).toHaveLogged(); + } ); + + test( 'should finish successfully when the script properly executed', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + + expect( () => spawnScript( scriptName ) ).toThrow( 'Exit code: 0.' ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', [ expect.stringContaining( scriptName ) ], { stdio: 'inherit' } + ); + } ); + + test( 'should finish successfully when the script properly executed with args', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + const args = [ '-a', '--bbb', '-c=ccccc' ]; + + expect( () => spawnScript( scriptName, args ) ).toThrow( 'Exit code: 0.' ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', [ expect.stringContaining( scriptName ), ...args ], { stdio: 'inherit' } + ); + } ); + } ); +} ); diff --git a/gb-src/packages/scripts/utils/test/string.js b/gb-src/packages/scripts/utils/test/string.js new file mode 100644 index 0000000000000..ca81bdda5df54 --- /dev/null +++ b/gb-src/packages/scripts/utils/test/string.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { camelCaseDash } from '../string'; + +describe( 'string', () => { + describe( 'camelCaseDash', () => { + test( 'does not change a single word', () => { + expect( camelCaseDash( 'blocks' ) ).toBe( 'blocks' ); + expect( camelCaseDash( 'dom' ) ).toBe( 'dom' ); + } ); + + test( 'does not capitalize letters following numbers', () => { + expect( camelCaseDash( 'a11y' ) ).toBe( 'a11y' ); + expect( camelCaseDash( 'i18n' ) ).toBe( 'i18n' ); + } ); + + test( 'converts dashes into camel case', () => { + expect( camelCaseDash( 'api-fetch' ) ).toBe( 'apiFetch' ); + expect( camelCaseDash( 'list-reusable-blocks' ) ).toBe( 'listReusableBlocks' ); + } ); + } ); +} ); diff --git a/gb-src/packages/server-side-render/.npmrc b/gb-src/packages/server-side-render/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/server-side-render/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/server-side-render/CHANGELOG.md b/gb-src/packages/server-side-render/CHANGELOG.md new file mode 100644 index 0000000000000..86eccbe1c29ef --- /dev/null +++ b/gb-src/packages/server-side-render/CHANGELOG.md @@ -0,0 +1,10 @@ +## 1.2.0 (2019-08-29) + +### Feature +- Add `EmptyResponsePlaceholder`, `ErrorResponsePlaceholder` and `LoadingResponsePlaceholder` render props for parent components to swap out alternate placeholders for the various states (see https://github.com/WordPress/gutenberg/pull/16512). + +## 1.0.0 (2019-06-12) + +### Initial Release + +- Extracted the package from `@wordpress/components` and `@wordpress/editor`; diff --git a/gb-src/packages/server-side-render/README.md b/gb-src/packages/server-side-render/README.md new file mode 100644 index 0000000000000..49bb3a7c94bb1 --- /dev/null +++ b/gb-src/packages/server-side-render/README.md @@ -0,0 +1,122 @@ +# ServerSideRender + +ServerSideRender is a component used for server-side rendering a preview of dynamic blocks to display in the editor. Server-side rendering in a block's `edit` function should be limited to blocks that are heavily dependent on existing PHP rendering logic that is heavily intertwined with data, particularly when there are no endpoints available. + +ServerSideRender may also be used when a legacy block is provided as a backward compatibility measure, rather than needing to re-write the deprecated code that the block may depend on. + +ServerSideRender should be regarded as a fallback or legacy mechanism, it is not appropriate for developing new features against. + +New blocks should be built in conjunction with any necessary REST API endpoints, so that JavaScript can be used for rendering client-side in the `edit` function. This gives the best user experience, instead of relying on using the PHP `render_callback`. The logic necessary for rendering should be included in the endpoint, so that both the client-side JavaScript and server-side PHP logic should require a minimal amount of differences. + +> This package is meant to be used only with WordPress core. Feel free to use it in your own project but please keep in mind that it might never get fully documented. + +## Installation + +Install the module + +```bash +npm install @wordpress/server-side-render --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +The props accepted by the component are described below. + + +## Props + +### attributes + +An object containing the attributes of the block to be server-side rendered. +E.g: `{ displayAsDropdown: true }`, `{ showHierarchy: true }`, etc... +- Type: `Object` +- Required: No + +### block + +The identifier of the block to be server-side rendered. +Examples: "core/archives", "core/latest-comments", "core/rss", etc... +- Type: `String` +- Required: Yes + +### className + +A class added to the DOM element that wraps the server side rendered block. +Examples: "my-custom-server-side-rendered". +- Type: `String` +- Required: No + +### urlQueryArgs + +Query arguments to apply to the request URL. +E.g: `{ post_id: 12 }`. +- Type: `Object` +- Required: No + +### EmptyResponsePlaceholder + +This is a [render prop](https://reactjs.org/docs/render-props.html). When the api response is empty, the value of this prop is rendered. The render prop will receive the value of the api response as well as all props passed into `ServerSideRenderer`. +- Type: `WPElement` +- Required: No + +### ErrorResponsePlaceholder + +This is a [render prop](https://reactjs.org/docs/render-props.html). When the api response is an error, the value of this prop is rendered. The render prop will receive the value of the api response as well as all props passed into `ServerSideRenderer`. +- Type: `WPElement` +- Required: No + +### LoadingResponsePlaceholder + +This is a [render prop](https://reactjs.org/docs/render-props.html). While the request is being processed (loading state), the value of this prop is rendered. The render prop will receive the value of the api response as well as all props passed into `ServerSideRenderer`. +- Type: `WPElement` +- Required: No + + +## Usage + +Render core/archives preview. + +```jsx +import { ServerSideRender } from '@wordpress/server-side-render'; + +const MyServerSideRender = () => ( + +); +``` + +## Output + +Output uses the block's `render_callback` function, set when defining the block. + +## API Endpoint + +The API endpoint for getting the output for ServerSideRender is `/wp/v2/block-renderer/:block`. It will use the block's `render_callback` method. + +If you pass `attributes` to `ServerSideRender`, the block must also be registered and have its attributes defined in PHP. + +```php +register_block_type( + 'core/archives', + array( + 'attributes' => array( + 'showPostCounts' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'displayAsDropdown' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'render_callback' => 'render_block_core_archives', + ) +); +``` diff --git a/gb-src/packages/server-side-render/package.json b/gb-src/packages/server-side-render/package.json new file mode 100644 index 0000000000000..b72bf58f21a87 --- /dev/null +++ b/gb-src/packages/server-side-render/package.json @@ -0,0 +1,38 @@ +{ + "name": "@wordpress/server-side-render", + "version": "1.3.6", + "description": "The component used with WordPress to server-side render a preview of dynamic blocks to display in the editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "server-side", + "render" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/server-side-render/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/server-side-render" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/components": "file:../components", + "@wordpress/data": "file:../data", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/url": "file:../url", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/server-side-render/src/index.js b/gb-src/packages/server-side-render/src/index.js new file mode 100644 index 0000000000000..d4881bcecdd21 --- /dev/null +++ b/gb-src/packages/server-side-render/src/index.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { useMemo, forwardRef } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import ServerSideRender from './server-side-render'; + +/** + * Constants + */ +const EMPTY_OBJECT = {}; + +const ExportedServerSideRender = withSelect( + ( select ) => { + const coreEditorSelect = select( 'core/editor' ); + if ( coreEditorSelect ) { + const currentPostId = coreEditorSelect.getCurrentPostId(); + if ( currentPostId ) { + return { + currentPostId, + }; + } + } + return EMPTY_OBJECT; + } +)( + ( { urlQueryArgs = EMPTY_OBJECT, currentPostId, ...props } ) => { + const newUrlQueryArgs = useMemo( () => { + if ( ! currentPostId ) { + return urlQueryArgs; + } + return { + post_id: currentPostId, + ...urlQueryArgs, + }; + }, [ currentPostId, urlQueryArgs ] ); + + return ( + + ); + } +); + +if ( window && window.wp && window.wp.components ) { + window.wp.components.ServerSideRender = forwardRef( ( props, ref ) => { + deprecated( 'wp.components.ServerSideRender', { + alternative: 'wp.serverSideRender', + } ); + return ( + + ); + } ); +} + +export default ExportedServerSideRender; diff --git a/gb-src/packages/server-side-render/src/server-side-render.js b/gb-src/packages/server-side-render/src/server-side-render.js new file mode 100644 index 0000000000000..dbb85f6d9b521 --- /dev/null +++ b/gb-src/packages/server-side-render/src/server-side-render.js @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { isEqual, debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + RawHTML, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { + Placeholder, + Spinner, +} from '@wordpress/components'; + +export function rendererPath( block, attributes = null, urlQueryArgs = {} ) { + return addQueryArgs( `/wp/v2/block-renderer/${ block }`, { + context: 'edit', + ...( null !== attributes ? { attributes } : {} ), + ...urlQueryArgs, + } ); +} + +export class ServerSideRender extends Component { + constructor( props ) { + super( props ); + this.state = { + response: null, + }; + } + + componentDidMount() { + this.isStillMounted = true; + this.fetch( this.props ); + // Only debounce once the initial fetch occurs to ensure that the first + // renders show data as soon as possible. + this.fetch = debounce( this.fetch, 500 ); + } + + componentWillUnmount() { + this.isStillMounted = false; + } + + componentDidUpdate( prevProps ) { + if ( ! isEqual( prevProps, this.props ) ) { + this.fetch( this.props ); + } + } + + fetch( props ) { + if ( ! this.isStillMounted ) { + return; + } + if ( null !== this.state.response ) { + this.setState( { response: null } ); + } + const { block, attributes = null, urlQueryArgs = {} } = props; + + const path = rendererPath( block, attributes, urlQueryArgs ); + // Store the latest fetch request so that when we process it, we can + // check if it is the current request, to avoid race conditions on slow networks. + const fetchRequest = this.currentFetchRequest = apiFetch( { path } ) + .then( ( response ) => { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest && response ) { + this.setState( { response: response.rendered } ); + } + } ) + .catch( ( error ) => { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest ) { + this.setState( { response: { + error: true, + errorMsg: error.message, + } } ); + } + } ); + return fetchRequest; + } + + render() { + const response = this.state.response; + const { className, EmptyResponsePlaceholder, ErrorResponsePlaceholder, LoadingResponsePlaceholder } = this.props; + + if ( response === '' ) { + return ( + + ); + } else if ( ! response ) { + return ( + + ); + } else if ( response.error ) { + return ( + + ); + } + + return ( + + { response } + + ); + } +} + +ServerSideRender.defaultProps = { + EmptyResponsePlaceholder: ( { className } ) => ( + + { __( 'Block rendered as empty.' ) + className } + + ), + ErrorResponsePlaceholder: ( { response, className } ) => { + // translators: %s: error message describing the problem + const errorMessage = sprintf( __( 'Error loading block: %s' ), response.errorMsg ); + return ( + + { errorMessage } + + ); + }, + LoadingResponsePlaceholder: ( { className } ) => { + return ( + + + + ); + }, +}; + +export default ServerSideRender; diff --git a/gb-src/packages/server-side-render/src/test/index.js b/gb-src/packages/server-side-render/src/test/index.js new file mode 100644 index 0000000000000..b97a80aa91b2b --- /dev/null +++ b/gb-src/packages/server-side-render/src/test/index.js @@ -0,0 +1,73 @@ +/** + * Internal dependencies + */ +import { rendererPath } from '../server-side-render'; + +describe( 'rendererPath', function() { + test( 'should return an base path for empty input', function() { + expect( rendererPath( 'core/test-block', null ) ).toBe( '/wp/v2/block-renderer/core/test-block?context=edit' ); + expect( rendererPath( 'core/test-block' ) ).toBe( '/wp/v2/block-renderer/core/test-block?context=edit' ); + } ); + + test( 'should format basic url params ', function() { + expect( + rendererPath( 'core/test-block', { + stringArg: 'test', + nullArg: null, + emptyArg: '', + numberArg: 123, + } ) + ).toBe( + '/wp/v2/block-renderer/core/test-block?context=edit&attributes%5BstringArg%5D=test&attributes%5BnullArg%5D=&attributes%5BemptyArg%5D=&attributes%5BnumberArg%5D=123', + ); + } ); + + test( 'should format object params ', function() { + expect( + rendererPath( 'core/test-block', { + objectArg: { + stringProp: 'test', + numberProp: 123, + }, + } ) + ).toBe( + '/wp/v2/block-renderer/core/test-block?context=edit&attributes%5BobjectArg%5D%5BstringProp%5D=test&attributes%5BobjectArg%5D%5BnumberProp%5D=123', + ); + } ); + + test( 'should format an array of objects', function() { + expect( + rendererPath( 'core/test-block', { + children: [ + { + name: 'bobby', + age: 12, + sex: 'M', + }, + { + name: 'sally', + age: 8, + sex: 'F', + }, + ], + } ) + ).toBe( + '/wp/v2/block-renderer/core/test-block?context=edit&attributes%5Bchildren%5D%5B0%5D%5Bname%5D=bobby&attributes%5Bchildren%5D%5B0%5D%5Bage%5D=12&attributes%5Bchildren%5D%5B0%5D%5Bsex%5D=M&attributes%5Bchildren%5D%5B1%5D%5Bname%5D=sally&attributes%5Bchildren%5D%5B1%5D%5Bage%5D=8&attributes%5Bchildren%5D%5B1%5D%5Bsex%5D=F', + ); + } ); + + test( 'should include urlQueryArgs', function() { + expect( + rendererPath( + 'core/test-block', { + stringArg: 'test', + }, + { + id: 1234, + } + ) + ).toBe( + '/wp/v2/block-renderer/core/test-block?context=edit&attributes%5BstringArg%5D=test&id=1234', + ); + } ); +} ); diff --git a/gb-src/packages/shortcode/.npmrc b/gb-src/packages/shortcode/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/shortcode/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/shortcode/CHANGELOG.md b/gb-src/packages/shortcode/CHANGELOG.md new file mode 100644 index 0000000000000..e0cbe15336f6a --- /dev/null +++ b/gb-src/packages/shortcode/CHANGELOG.md @@ -0,0 +1,11 @@ +## 2.0.1 (2018-09-30) + +### Bug Fixes + +- Fix missing memize dependency in package.json + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/shortcode/README.md b/gb-src/packages/shortcode/README.md new file mode 100644 index 0000000000000..1179a3a3d2472 --- /dev/null +++ b/gb-src/packages/shortcode/README.md @@ -0,0 +1,148 @@ +# Shortcode + +Shortcode module for WordPress. + +## Installation + +Install the module + +```bash +npm install @wordpress/shortcode --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + + + +# **attrs** + +Parse shortcode attributes. + +Shortcodes accept many types of attributes. These can chiefly be divided into +named and numeric attributes: + +Named attributes are assigned on a key/value basis, while numeric attributes +are treated as an array. + +Named attributes can be formatted as either `name="value"`, `name='value'`, +or `name=value`. Numeric attributes can be formatted as `"value"` or just +`value`. + +_Parameters_ + +- _text_ `string`: Serialised shortcode attributes. + +_Returns_ + +- `WPShortcodeAttrs`: Parsed shortcode attributes. + +# **default** + +Creates a shortcode instance. + +To access a raw representation of a shortcode, pass an `options` object, +containing a `tag` string, a string or object of `attrs`, a string indicating +the `type` of the shortcode ('single', 'self-closing', or 'closed'), and a +`content` string. + +_Parameters_ + +- _options_ `Object`: Options as described. + +_Returns_ + +- `WPShortcode`: Shortcode instance. + +# **fromMatch** + +Generate a Shortcode Object from a RegExp match. + +Accepts a `match` object from calling `regexp.exec()` on a `RegExp` generated +by `regexp()`. `match` can also be set to the `arguments` from a callback +passed to `regexp.replace()`. + +_Parameters_ + +- _match_ `Array`: Match array. + +_Returns_ + +- `WPShortcode`: Shortcode instance. + +# **next** + +Find the next matching shortcode. + +_Parameters_ + +- _tag_ `string`: Shortcode tag. +- _text_ `string`: Text to search. +- _index_ `number`: Index to start search from. + +_Returns_ + +- `?WPShortcodeMatch`: Matched information. + +# **regexp** + +Generate a RegExp to identify a shortcode. + +The base regex is functionally equivalent to the one found in +`get_shortcode_regex()` in `wp-includes/shortcodes.php`. + +Capture groups: + +1. An extra `[` to allow for escaping shortcodes with double `[[]]` +2. The shortcode name +3. The shortcode argument list +4. The self closing `/` +5. The content of a shortcode when it wraps some content. +6. The closing tag. +7. An extra `]` to allow for escaping shortcodes with double `[[]]` + +_Parameters_ + +- _tag_ `string`: Shortcode tag. + +_Returns_ + +- `RegExp`: Shortcode RegExp. + +# **replace** + +Replace matching shortcodes in a block of text. + +_Parameters_ + +- _tag_ `string`: Shortcode tag. +- _text_ `string`: Text to search. +- _callback_ `Function`: Function to process the match and return replacement string. + +_Returns_ + +- `string`: Text with shortcodes replaced. + +# **string** + +Generate a string from shortcode parameters. + +Creates a shortcode instance and returns a string. + +Accepts the same `options` as the `shortcode()` constructor, containing a +`tag` string, a string or object of `attrs`, a boolean indicating whether to +format the shortcode using a `single` tag, and a `content` string. + +_Parameters_ + +- _options_ `Object`: + +_Returns_ + +- `string`: String representation of the shortcode. + + + + +

    Code is Poetry.

    diff --git a/gb-src/packages/shortcode/package.json b/gb-src/packages/shortcode/package.json new file mode 100644 index 0000000000000..a5307aeec9bba --- /dev/null +++ b/gb-src/packages/shortcode/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/shortcode", + "version": "2.4.1", + "description": "Shortcode module for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "shortcode" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/shortcode/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/shortcode" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15", + "memize": "^1.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/shortcode/src/index.js b/gb-src/packages/shortcode/src/index.js new file mode 100644 index 0000000000000..ef609e4a3d170 --- /dev/null +++ b/gb-src/packages/shortcode/src/index.js @@ -0,0 +1,363 @@ +/** + * External dependencies + */ +import { extend, pick, isString, isEqual, forEach, isNumber } from 'lodash'; +import memize from 'memize'; + +/** + * Shortcode attributes object. + * + * @typedef {Object} WPShortcodeAttrs + * + * @property {Object} named Object with named attributes. + * @property {Array} numeric Array with numeric attributes. + */ + +/** + * Shortcode object. + * + * @typedef {Object} WPShortcode + * + * @property {string} tag Shortcode tag. + * @property {WPShortcodeAttrs} attrs Shortcode attributes. + * @property {string} content Shortcode content. + * @property {string} type Shortcode type: `self-closing`, + * `closed`, or `single`. + */ + +/** + * @typedef {Object} WPShortcodeMatch + * + * @property {number} index Index the shortcode is found at. + * @property {string} content Matched content. + * @property {WPShortcode} shortcode Shortcode instance of the match. + */ + +/** + * Find the next matching shortcode. + * + * @param {string} tag Shortcode tag. + * @param {string} text Text to search. + * @param {number} index Index to start search from. + * + * @return {?WPShortcodeMatch} Matched information. + */ +export function next( tag, text, index = 0 ) { + const re = regexp( tag ); + + re.lastIndex = index; + + const match = re.exec( text ); + + if ( ! match ) { + return; + } + + // If we matched an escaped shortcode, try again. + if ( '[' === match[ 1 ] && ']' === match[ 7 ] ) { + return next( tag, text, re.lastIndex ); + } + + const result = { + index: match.index, + content: match[ 0 ], + shortcode: fromMatch( match ), + }; + + // If we matched a leading `[`, strip it from the match and increment the + // index accordingly. + if ( match[ 1 ] ) { + result.content = result.content.slice( 1 ); + result.index++; + } + + // If we matched a trailing `]`, strip it from the match. + if ( match[ 7 ] ) { + result.content = result.content.slice( 0, -1 ); + } + + return result; +} + +/** + * Replace matching shortcodes in a block of text. + * + * @param {string} tag Shortcode tag. + * @param {string} text Text to search. + * @param {Function} callback Function to process the match and return + * replacement string. + * + * @return {string} Text with shortcodes replaced. + */ +export function replace( tag, text, callback ) { + return text.replace( regexp( tag ), function( match, left, $3, attrs, slash, content, closing, right ) { + // If both extra brackets exist, the shortcode has been properly + // escaped. + if ( left === '[' && right === ']' ) { + return match; + } + + // Create the match object and pass it through the callback. + const result = callback( fromMatch( arguments ) ); + + // Make sure to return any of the extra brackets if they weren't used to + // escape the shortcode. + return result ? left + result + right : match; + } ); +} + +/** + * Generate a string from shortcode parameters. + * + * Creates a shortcode instance and returns a string. + * + * Accepts the same `options` as the `shortcode()` constructor, containing a + * `tag` string, a string or object of `attrs`, a boolean indicating whether to + * format the shortcode using a `single` tag, and a `content` string. + * + * @param {Object} options + * + * @return {string} String representation of the shortcode. + */ +export function string( options ) { + return new shortcode( options ).string(); +} + +/** + * Generate a RegExp to identify a shortcode. + * + * The base regex is functionally equivalent to the one found in + * `get_shortcode_regex()` in `wp-includes/shortcodes.php`. + * + * Capture groups: + * + * 1. An extra `[` to allow for escaping shortcodes with double `[[]]` + * 2. The shortcode name + * 3. The shortcode argument list + * 4. The self closing `/` + * 5. The content of a shortcode when it wraps some content. + * 6. The closing tag. + * 7. An extra `]` to allow for escaping shortcodes with double `[[]]` + * + * @param {string} tag Shortcode tag. + * + * @return {RegExp} Shortcode RegExp. + */ +export function regexp( tag ) { + return new RegExp( '\\[(\\[?)(' + tag + ')(?![\\w-])([^\\]\\/]*(?:\\/(?!\\])[^\\]\\/]*)*?)(?:(\\/)\\]|\\](?:([^\\[]*(?:\\[(?!\\/\\2\\])[^\\[]*)*)(\\[\\/\\2\\]))?)(\\]?)', 'g' ); +} + +/** + * Parse shortcode attributes. + * + * Shortcodes accept many types of attributes. These can chiefly be divided into + * named and numeric attributes: + * + * Named attributes are assigned on a key/value basis, while numeric attributes + * are treated as an array. + * + * Named attributes can be formatted as either `name="value"`, `name='value'`, + * or `name=value`. Numeric attributes can be formatted as `"value"` or just + * `value`. + * + * @param {string} text Serialised shortcode attributes. + * + * @return {WPShortcodeAttrs} Parsed shortcode attributes. + */ +export const attrs = memize( ( text ) => { + const named = {}; + const numeric = []; + + // This regular expression is reused from `shortcode_parse_atts()` in + // `wp-includes/shortcodes.php`. + // + // Capture groups: + // + // 1. An attribute name, that corresponds to... + // 2. a value in double quotes. + // 3. An attribute name, that corresponds to... + // 4. a value in single quotes. + // 5. An attribute name, that corresponds to... + // 6. an unquoted value. + // 7. A numeric attribute in double quotes. + // 8. A numeric attribute in single quotes. + // 9. An unquoted numeric attribute. + const pattern = /([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*'([^']*)'(?:\s|$)|([\w-]+)\s*=\s*([^\s'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|'([^']*)'(?:\s|$)|(\S+)(?:\s|$)/g; + + // Map zero-width spaces to actual spaces. + text = text.replace( /[\u00a0\u200b]/g, ' ' ); + + let match; + + // Match and normalize attributes. + while ( ( match = pattern.exec( text ) ) ) { + if ( match[ 1 ] ) { + named[ match[ 1 ].toLowerCase() ] = match[ 2 ]; + } else if ( match[ 3 ] ) { + named[ match[ 3 ].toLowerCase() ] = match[ 4 ]; + } else if ( match[ 5 ] ) { + named[ match[ 5 ].toLowerCase() ] = match[ 6 ]; + } else if ( match[ 7 ] ) { + numeric.push( match[ 7 ] ); + } else if ( match[ 8 ] ) { + numeric.push( match[ 8 ] ); + } else if ( match[ 9 ] ) { + numeric.push( match[ 9 ] ); + } + } + + return { named, numeric }; +} ); + +/** + * Generate a Shortcode Object from a RegExp match. + * + * Accepts a `match` object from calling `regexp.exec()` on a `RegExp` generated + * by `regexp()`. `match` can also be set to the `arguments` from a callback + * passed to `regexp.replace()`. + * + * @param {Array} match Match array. + * + * @return {WPShortcode} Shortcode instance. + */ +export function fromMatch( match ) { + let type; + + if ( match[ 4 ] ) { + type = 'self-closing'; + } else if ( match[ 6 ] ) { + type = 'closed'; + } else { + type = 'single'; + } + + return new shortcode( { + tag: match[ 2 ], + attrs: match[ 3 ], + type, + content: match[ 5 ], + } ); +} + +/** + * Creates a shortcode instance. + * + * To access a raw representation of a shortcode, pass an `options` object, + * containing a `tag` string, a string or object of `attrs`, a string indicating + * the `type` of the shortcode ('single', 'self-closing', or 'closed'), and a + * `content` string. + * + * @param {Object} options Options as described. + * + * @return {WPShortcode} Shortcode instance. + */ +const shortcode = extend( function( options ) { + extend( this, pick( options || {}, 'tag', 'attrs', 'type', 'content' ) ); + + const attributes = this.attrs; + + // Ensure we have a correctly formatted `attrs` object. + this.attrs = { + named: {}, + numeric: [], + }; + + if ( ! attributes ) { + return; + } + + // Parse a string of attributes. + if ( isString( attributes ) ) { + this.attrs = attrs( attributes ); + // Identify a correctly formatted `attrs` object. + } else if ( isEqual( Object.keys( attributes ), [ 'named', 'numeric' ] ) ) { + this.attrs = attributes; + // Handle a flat object of attributes. + } else { + forEach( attributes, ( value, key ) => { + this.set( key, value ); + } ); + } +}, { + next, + replace, + string, + regexp, + attrs, + fromMatch, +} ); + +extend( shortcode.prototype, { + + /** + * Get a shortcode attribute. + * + * Automatically detects whether `attr` is named or numeric and routes it + * accordingly. + * + * @param {(number|string)} attr Attribute key. + * + * @return {string} Attribute value. + */ + get( attr ) { + return this.attrs[ isNumber( attr ) ? 'numeric' : 'named' ][ attr ]; + }, + + /** + * Set a shortcode attribute. + * + * Automatically detects whether `attr` is named or numeric and routes it + * accordingly. + * + * @param {(number|string)} attr Attribute key. + * @param {string} value Attribute value. + * + * @return {WPShortcode} Shortcode instance. + */ + set( attr, value ) { + this.attrs[ isNumber( attr ) ? 'numeric' : 'named' ][ attr ] = value; + return this; + }, + + /** + * Transform the shortcode into a string. + * + * @return {string} String representation of the shortcode. + */ + string() { + let text = '[' + this.tag; + + forEach( this.attrs.numeric, ( value ) => { + if ( /\s/.test( value ) ) { + text += ' "' + value + '"'; + } else { + text += ' ' + value; + } + } ); + + forEach( this.attrs.named, ( value, name ) => { + text += ' ' + name + '="' + value + '"'; + } ); + + // If the tag is marked as `single` or `self-closing`, close the tag and + // ignore any additional content. + if ( 'single' === this.type ) { + return text + ']'; + } else if ( 'self-closing' === this.type ) { + return text + ' /]'; + } + + // Complete the opening tag. + text += ']'; + + if ( this.content ) { + text += this.content; + } + + // Add the closing tag. + return text + '[/' + this.tag + ']'; + }, + +} ); + +export default shortcode; diff --git a/gb-src/packages/shortcode/src/test/index.js b/gb-src/packages/shortcode/src/test/index.js new file mode 100644 index 0000000000000..9b3ca086abbec --- /dev/null +++ b/gb-src/packages/shortcode/src/test/index.js @@ -0,0 +1,205 @@ +/** + * Internal dependencies + */ +import { next, replace, attrs } from '../'; + +describe( 'shortcode', () => { + describe( 'next', () => { + it( 'should find the shortcode', () => { + const result = next( 'foo', 'this has the [foo] shortcode' ); + expect( result.index ).toBe( 13 ); + } ); + + it( 'should find the shortcode with attributes', () => { + const result = next( 'foo', 'this has the [foo param="foo"] shortcode' ); + expect( result.index ).toBe( 13 ); + } ); + + it( 'should not find shortcodes that are not there', () => { + const result = next( 'bar', 'this has the [foo] shortcode' ); + expect( result ).toBe( undefined ); + } ); + + it( 'should not find shortcodes with attributes that are not there', () => { + const result = next( 'bar', 'this has the [foo param="bar"] shortcode' ); + expect( result ).toBe( undefined ); + } ); + + it( 'should find the shortcode when told to start looking beyond the start of the string', () => { + const result1 = next( 'foo', 'this has the [foo] shortcode', 12 ); + expect( result1.index ).toBe( 13 ); + + const result2 = next( 'foo', 'this has the [foo] shortcode', 13 ); + expect( result2.index ).toBe( 13 ); + + const result3 = next( 'foo', 'this has the [foo] shortcode', 14 ); + expect( result3 ).toBe( undefined ); + } ); + + it( 'should find the second instances of the shortcode when the starting indice is after the start of the first one', () => { + const result = next( 'foo', 'this has the [foo] shortcode [foo] twice', 14 ); + expect( result.index ).toBe( 29 ); + } ); + + it( 'should not find escaped shortcodes', () => { + const result = next( 'foo', 'this has the [[foo]] shortcode' ); + expect( result ).toBe( undefined ); + } ); + + it( 'should not find escaped shortcodes with attributes', () => { + const result = next( 'foo', 'this has the [[foo param="foo"]] shortcode' ); + expect( result ).toBe( undefined ); + } ); + + it( 'should find shortcodes that are incorrectly escaped by newlines', () => { + const result1 = next( 'foo', 'this has the [\n[foo]] shortcode' ); + expect( result1.index ).toBe( 15 ); + + const result2 = next( 'foo', 'this has the [[foo]\n] shortcode' ); + expect( result2.index ).toBe( 14 ); + } ); + + it( 'should still work when there are not equal amounts of square brackets', () => { + const result1 = next( 'foo', 'this has the [[foo] shortcode' ); + expect( result1.index ).toBe( 14 ); + + const result2 = next( 'foo', 'this has the [foo]] shortcode' ); + expect( result2.index ).toBe( 13 ); + } ); + + it( 'should find the second instances of the shortcode when the first one is escaped', () => { + const result = next( 'foo', 'this has the [[foo]] shortcode [foo] twice' ); + expect( result.index ).toBe( 31 ); + } ); + + it( 'should not find shortcodes that are not full matches', () => { + const result1 = next( 'foo', 'this has the [foobar] shortcode' ); + expect( result1 ).toBe( undefined ); + + const result2 = next( 'foobar', 'this has the [foo] shortcode' ); + expect( result2 ).toBe( undefined ); + } ); + } ); + + describe( 'replace', () => { + it( 'should replace the shortcode', () => { + const result1 = replace( 'foo', 'this has the [foo] shortcode', () => 'bar' ); + expect( result1 ).toBe( 'this has the bar shortcode' ); + + const result2 = replace( 'foo', 'this has the [foo param="foo"] shortcode', () => 'bar' ); + expect( result2 ).toBe( 'this has the bar shortcode' ); + } ); + + it( 'should replace the shortcode with data from an attribute', () => { + const result1 = replace( 'foo', 'this [foo param="replacement text"] came from a shortcode attribute', ( match ) => { + return match.attrs.named.param || ''; + } ); + expect( result1 ).toBe( 'this replacement text came from a shortcode attribute' ); + } ); + + it( 'should not replace the shortcode when it does not match', () => { + const result1 = replace( 'bar', 'this has the [foo] shortcode', () => 'bar' ); + expect( result1 ).toBe( 'this has the [foo] shortcode' ); + + const result2 = replace( 'bar', 'this has the [foo param="bar"] shortcode', () => 'bar' ); + expect( result2 ).toBe( 'this has the [foo param="bar"] shortcode' ); + } ); + + it( 'should replace the shortcode in all instances of its use', () => { + const result1 = replace( 'foo', 'this has the [foo] shortcode [foo] twice', () => 'bar' ); + expect( result1 ).toBe( 'this has the bar shortcode bar twice' ); + + const result2 = replace( 'foo', 'this has the [foo param="foo"] shortcode [foo] twice', () => 'bar' ); + expect( result2 ).toBe( 'this has the bar shortcode bar twice' ); + } ); + + it( 'should not replace the escaped shortcodes', () => { + const result1 = replace( 'foo', 'this has the [[foo]] shortcode', () => 'bar' ); + expect( result1 ).toBe( 'this has the [[foo]] shortcode' ); + + const result2 = replace( 'foo', 'this has the [[foo param="bar"]] shortcode', () => 'bar' ); + expect( result2 ).toBe( 'this has the [[foo param="bar"]] shortcode' ); + + const result3 = replace( 'foo', 'this [foo] has the [[foo param="bar"]] shortcode escaped', () => 'bar' ); + expect( result3 ).toBe( 'this bar has the [[foo param="bar"]] shortcode escaped' ); + } ); + + it( 'should replace improperly escaped shortcodes that include newlines', () => { + const result1 = replace( 'foo', 'this [foo] has the [[foo param="bar"]\n] shortcode ', () => 'bar' ); + expect( result1 ).toBe( 'this bar has the [bar\n] shortcode ' ); + + const result2 = replace( 'foo', 'this [foo] has the [\n[foo param="bar"]] shortcode ', () => 'bar' ); + expect( result2 ).toBe( 'this bar has the [\nbar] shortcode ' ); + } ); + + it( 'should not replace the shortcode when it is an incomplete match', () => { + const result1 = replace( 'foo', 'this has the [foobar] shortcode', () => 'bar' ); + expect( result1 ).toBe( 'this has the [foobar] shortcode' ); + + const result2 = replace( 'foobar', 'this has the [foo] shortcode', () => 'bar' ); + expect( result2 ).toBe( 'this has the [foo] shortcode' ); + } ); + } ); + + describe( 'attrs', () => { + it( 'should return named attributes created with single, double, and no quotes', () => { + const result = attrs( `param="foo" another='bar' andagain=baz` ); + const expected = { + named: { + param: 'foo', + another: 'bar', + andagain: 'baz', + }, + numeric: [], + }; + + expect( result ).toEqual( expected ); + } ); + + it( 'should return numeric attributes in the order they are used', () => { + const result = attrs( 'foo bar baz' ); + const expected = { + named: {}, + numeric: [ 'foo', 'bar', 'baz' ], + }; + + expect( result ).toEqual( expected ); + } ); + + it( 'should return numeric attributes in the order they are used when they have named attributes in between', () => { + const result = attrs( 'foo not="a blocker" bar baz' ); + const expected = { + named: { + not: 'a blocker', + }, + numeric: [ 'foo', 'bar', 'baz' ], + }; + + expect( result ).toEqual( expected ); + } ); + + it( 'should return numeric attributes created with single, double, and no quotes', () => { + const result = attrs( `foo "bar" 'baz'` ); + const expected = { + named: {}, + numeric: [ 'foo', 'bar', 'baz' ], + }; + + expect( result ).toEqual( expected ); + } ); + + it( 'should return mixed attributes created with single, double, and no quotes', () => { + const result = attrs( `a="foo" b='bar' c=baz foo "bar" 'baz'` ); + const expected = { + named: { + a: 'foo', + b: 'bar', + c: 'baz', + }, + numeric: [ 'foo', 'bar', 'baz' ], + }; + + expect( result ).toEqual( expected ); + } ); + } ); +} ); diff --git a/gb-src/packages/token-list/.npmrc b/gb-src/packages/token-list/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/token-list/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/token-list/CHANGELOG.md b/gb-src/packages/token-list/CHANGELOG.md new file mode 100644 index 0000000000000..4f09e8ebd8395 --- /dev/null +++ b/gb-src/packages/token-list/CHANGELOG.md @@ -0,0 +1,10 @@ +## 1.1.0 (2018-11-20) + +### Enhancements + +- Implements missing stringification behavior (i.e. `toString`), as prescribed in the standard to be the `value` property (the interface's `stringifier`) +- Implements missing iterator behavior + +## 1.0.0 (2018-09-05) + +- Initial release diff --git a/gb-src/packages/token-list/README.md b/gb-src/packages/token-list/README.md new file mode 100644 index 0000000000000..04be66d56b893 --- /dev/null +++ b/gb-src/packages/token-list/README.md @@ -0,0 +1,49 @@ +# Token List + +Constructable, plain JavaScript [DOMTokenList](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList) implementation, supporting non-browser runtimes. + +## Installation + +Install the module + +```bash +npm install @wordpress/token-list +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +Construct a new token list, optionally with an initial value. A value with an interface matching DOMTokenList is returned. + +```js +import TokenList from '@wordpress/token-list'; + +const tokens = new TokenList( 'abc def' ); +tokens.add( 'ghi' ); +tokens.remove( 'def' ); +tokens.replace( 'abc', 'xyz' ); +console.log( tokens.value ); +// "xyz ghi" +``` + +All [methods of DOMTokenList](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList#Methods) are implemented. + +Note the following implementation divergences from the [specification](https://dom.spec.whatwg.org/#domtokenlist): + +- `TokenList#supports` will always return true, regardless of the token passed. +- `TokenList#add` and `TokenList#remove` will simply disregard the empty string argument or whitespace of a token argument, rather than [throwing an error](https://dom.spec.whatwg.org/#dom-domtokenlist-add). +- An item cannot be referenced by its index as a property. Use `TokenList#item` instead. + +## Browser Support + +While it could be used in one's implementation, this is not intended to serve as a polyfill for `Element#classList` or other instances of `DOMTokenList`. + +The implementation of the `DOMTokenList` interface provided through `@wordpress/token-list` is broadly compatible in environments supporting ES5 (IE8 and newer). That being said, due to its internal implementation leveraging arrays for `TokenList#entries`, `TokenList#forEach`, `TokenList#keys`, and `TokenList#values`, you may need to assure that these functions are supported or polyfilled if you intend to use them. + +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/entries#Browser_compatibility +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/values#Browser_compatibility + +TokenList's own internal implementation of the `DOMTokenList` interface does not leverage any of these functions, so it is not necessary to polyfill them for basic usage. + +

    Code is Poetry.

    diff --git a/gb-src/packages/token-list/package.json b/gb-src/packages/token-list/package.json new file mode 100644 index 0000000000000..efd69276eaad2 --- /dev/null +++ b/gb-src/packages/token-list/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wordpress/token-list", + "version": "1.6.1", + "description": "Constructable, plain JavaScript DOMTokenList implementation, supporting non-browser runtimes.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "domtokenlist" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/token-list/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/token-list" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/token-list/src/index.js b/gb-src/packages/token-list/src/index.js new file mode 100644 index 0000000000000..e97bfabaa77cf --- /dev/null +++ b/gb-src/packages/token-list/src/index.js @@ -0,0 +1,195 @@ +/** + * External dependencies + */ +import { uniq, compact, without } from 'lodash'; + +/** + * A set of tokens. + * + * @see https://dom.spec.whatwg.org/#domtokenlist + */ +export default class TokenList { + /** + * Constructs a new instance of TokenList. + * + * @param {string} initialValue Initial value to assign. + */ + constructor( initialValue = '' ) { + this.value = initialValue; + + [ 'entries', 'forEach', 'keys', 'values' ].forEach( ( fn ) => { + this[ fn ] = ( function() { + return this._valueAsArray[ fn ]( ...arguments ); + } ).bind( this ); + } ); + } + + /** + * Returns the associated set as string. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-value + * + * @return {string} Token set as string. + */ + get value() { + return this._currentValue; + } + + /** + * Replaces the associated set with a new string value. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-value + * + * @param {string} value New token set as string. + */ + set value( value ) { + value = String( value ); + this._valueAsArray = uniq( compact( value.split( /\s+/g ) ) ); + this._currentValue = this._valueAsArray.join( ' ' ); + } + + /** + * Returns the number of tokens. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-length + * + * @return {number} Number of tokens. + */ + get length() { + return this._valueAsArray.length; + } + + /** + * Returns the stringified form of the TokenList. + * + * @see https://dom.spec.whatwg.org/#DOMTokenList-stringification-behavior + * @see https://www.ecma-international.org/ecma-262/9.0/index.html#sec-tostring + * + * @return {string} Token set as string. + */ + toString() { + return this.value; + } + + /** + * Returns an iterator for the TokenList, iterating items of the set. + * + * @see https://dom.spec.whatwg.org/#domtokenlist + * + * @return {IterableIterator} TokenList iterator. + */ + * [ Symbol.iterator ]() { + return yield* this._valueAsArray; + } + + /** + * Returns the token with index `index`. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-item + * + * @param {number} index Index at which to return token. + * + * @return {?string} Token at index. + */ + item( index ) { + return this._valueAsArray[ index ]; + } + + /** + * Returns true if `token` is present, and false otherwise. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-contains + * + * @param {string} item Token to test. + * + * @return {boolean} Whether token is present. + */ + contains( item ) { + return this._valueAsArray.indexOf( item ) !== -1; + } + + /** + * Adds all arguments passed, except those already present. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-add + * + * @param {...string} items Items to add. + */ + add( ...items ) { + this.value += ' ' + items.join( ' ' ); + } + + /** + * Removes arguments passed, if they are present. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-remove + * + * @param {...string} items Items to remove. + */ + remove( ...items ) { + this.value = without( this._valueAsArray, ...items ).join( ' ' ); + } + + /** + * If `force` is not given, "toggles" `token`, removing it if it’s present + * and adding it if it’s not present. If `force` is true, adds token (same + * as add()). If force is false, removes token (same as remove()). Returns + * true if `token` is now present, and false otherwise. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-toggle + * + * @param {string} token Token to toggle. + * @param {?boolean} force Presence to force. + * + * @return {boolean} Whether token is present after toggle. + */ + toggle( token, force ) { + if ( undefined === force ) { + force = ! this.contains( token ); + } + + if ( force ) { + this.add( token ); + } else { + this.remove( token ); + } + + return force; + } + + /** + * Replaces `token` with `newToken`. Returns true if `token` was replaced + * with `newToken`, and false otherwise. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-replace + * + * @param {string} token Token to replace with `newToken`. + * @param {string} newToken Token to use in place of `token`. + * + * @return {boolean} Whether replacement occurred. + */ + replace( token, newToken ) { + if ( ! this.contains( token ) ) { + return false; + } + + this.remove( token ); + this.add( newToken ); + + return true; + } + + /** + * Returns true if `token` is in the associated attribute’s supported + * tokens. Returns false otherwise. + * + * Always returns `true` in this implementation. + * + * @see https://dom.spec.whatwg.org/#dom-domtokenlist-supports + * + * @return {boolean} Whether token is supported. + */ + supports() { + return true; + } +} diff --git a/gb-src/packages/token-list/src/test/index.js b/gb-src/packages/token-list/src/test/index.js new file mode 100644 index 0000000000000..d221abd620370 --- /dev/null +++ b/gb-src/packages/token-list/src/test/index.js @@ -0,0 +1,281 @@ +/** + * Internal dependencies + */ +import TokenList from '../'; + +describe( 'token-list', () => { + describe( 'constructor', () => { + it( 'should instantiate instance with no initial value', () => { + const list = new TokenList(); + + expect( list.value ).toBe( '' ); + expect( list ).toHaveLength( 0 ); + } ); + + it( 'should instantiate instance with empty initial value', () => { + const list = new TokenList( '' ); + + expect( list.value ).toBe( '' ); + expect( list ).toHaveLength( 0 ); + } ); + + it( 'should instantiate instance with initial value', () => { + const list = new TokenList( 'abc ' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + } ); + + describe( 'array method inheritence', () => { + it( 'entries', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.entries() ] ).toEqual( [ [ 0, 'abc' ] ] ); + } ); + + it( 'forEach', () => { + expect.assertions( 1 ); + + const list = new TokenList( 'abc ' ); + + list.forEach( ( item ) => expect( item ).toBe( 'abc' ) ); + } ); + + it( 'values', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.values() ] ).toEqual( [ 'abc' ] ); + } ); + + it( 'keys', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.keys() ] ).toEqual( [ 0 ] ); + } ); + } ); + } ); + + describe( 'value', () => { + it( 'gets the stringified value', () => { + const list = new TokenList( 'abc ' ); + + expect( list.value ).toBe( 'abc' ); + } ); + + it( 'sets to stringified value', () => { + const list = new TokenList(); + list.value = undefined; + + expect( list.value ).toBe( 'undefined' ); + } ); + + it( 'is the stringifier of the instance', () => { + const list = new TokenList( 'abc ' ); + + expect( String( list ) ).toBe( 'abc' ); + } ); + } ); + + describe( 'Symbol.iterator', () => { + it( 'returns a generator', () => { + const list = new TokenList(); + + expect( list[ Symbol.iterator ]().next ).toEqual( expect.any( Function ) ); + } ); + + it( 'yields entries', () => { + expect.assertions( 2 ); + + const classes = [ 'abc', 'def' ]; + const list = new TokenList( classes.join( ' ' ) ); + + let i = 0; + for ( const item of list ) { + expect( item ).toBe( classes[ i++ ] ); + } + } ); + } ); + + describe( 'item', () => { + it( 'should return undefined if item at index does not exist', () => { + const list = new TokenList(); + + expect( list.item( 0 ) ).toBeUndefined(); + } ); + + it( 'should return item at index', () => { + const list = new TokenList( 'abc' ); + + expect( list.item( 0 ) ).toBe( 'abc' ); + } ); + } ); + + describe( 'contains', () => { + it( 'should return false if token does not exist', () => { + const list = new TokenList(); + + expect( list.contains( 'abc' ) ).toBe( false ); + } ); + + it( 'should return true if token exists', () => { + const list = new TokenList( 'abc' ); + + expect( list.contains( 'abc' ) ).toBe( true ); + } ); + } ); + + describe( 'add', () => { + it( 'does nothing if token already exists', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.add( 'abc' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'does nothing if token already exists (whitespace variation)', () => { + const list = new TokenList( 'abc ' ); + const returnValue = list.add( '' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'does not add an empty token', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.add( '' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'adds token to empty initial value', () => { + const list = new TokenList(); + const returnValue = list.add( 'abc' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'adds token to non-empty initial value', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.add( 'def' ); + + expect( list.value ).toBe( 'abc def' ); + expect( list ).toHaveLength( 2 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'adds multiple tokens, uniquely', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.add( 'abc', 'def', 'ghi' ); + + expect( list.value ).toBe( 'abc def ghi' ); + expect( list ).toHaveLength( 3 ); + expect( returnValue ).toBeUndefined(); + } ); + } ); + + describe( 'remove', () => { + it( 'does nothing if token does not exist', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.remove( 'def' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'removes token', () => { + const list = new TokenList( 'abc def' ); + const returnValue = list.remove( 'def' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBeUndefined(); + } ); + + it( 'removes multiple tokens', () => { + const list = new TokenList( 'abc def' ); + const returnValue = list.remove( 'abc', 'def' ); + + expect( list.value ).toBe( '' ); + expect( list ).toHaveLength( 0 ); + expect( returnValue ).toBeUndefined(); + } ); + } ); + + describe( 'replace', () => { + it( 'does nothing if token does not exist', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.replace( 'def', 'ghi' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBe( false ); + } ); + + it( 'removes token', () => { + const list = new TokenList( 'abc def' ); + const returnValue = list.replace( 'def', 'ghi' ); + + expect( list.value ).toBe( 'abc ghi' ); + expect( list ).toHaveLength( 2 ); + expect( returnValue ).toBe( true ); + } ); + } ); + + describe( 'supports', () => { + it( 'returns true', () => { + const list = new TokenList( 'abc def' ); + + expect( list.supports( 'does-not-matter' ) ).toBe( true ); + } ); + } ); + + describe( 'toggle', () => { + describe( 'without force', () => { + it( 'toggles off', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.toggle( 'abc' ); + + expect( list.value ).toBe( '' ); + expect( list ).toHaveLength( 0 ); + expect( returnValue ).toBe( false ); + } ); + + it( 'toggles on', () => { + const list = new TokenList(); + const returnValue = list.toggle( 'abc' ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBe( true ); + } ); + } ); + + describe( 'with force', () => { + it( 'toggles off', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.toggle( 'abc', false ); + + expect( list.value ).toBe( '' ); + expect( list ).toHaveLength( 0 ); + expect( returnValue ).toBe( false ); + } ); + + it( 'toggles on', () => { + const list = new TokenList( 'abc' ); + const returnValue = list.toggle( 'abc', true ); + + expect( list.value ).toBe( 'abc' ); + expect( list ).toHaveLength( 1 ); + expect( returnValue ).toBe( true ); + } ); + } ); + } ); +} ); diff --git a/gb-src/packages/url/.npmrc b/gb-src/packages/url/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/url/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/url/CHANGELOG.md b/gb-src/packages/url/CHANGELOG.md new file mode 100644 index 0000000000000..3c0ec6a60d906 --- /dev/null +++ b/gb-src/packages/url/CHANGELOG.md @@ -0,0 +1,56 @@ +## 2.3.3 (2019-01-03) + +### Bug Fixes + +- `addQueryArgs` will return only the querystring fragment if the passed `url` is undefined. Previously, an uncaught error would be thrown. +- `addQueryArgs` will not append (or remove) a `?` if there are no query arguments to be added. Previously, `?` would be wrongly appended even if there was no querystring generated. + +## 2.3.2 (2018-12-12) + +## 2.3.1 (2018-11-20) + +### Bug fixes + +- The `isValidProtocol` function now correctly considers the protocol of the URL as only incoporating characters up to and including the colon (':'). +- `getFragment` is now greedier and matches fragments from the first occurence of the '#' symbol instead of the last. + +## 2.3.0 (2018-11-12) + +### New Features + +- Added `getProtocol`. +- Added `isValidProtocol`. +- Added `getAuthority` +- Added `isValidAuthority`. +- Added `getPath`. +- Added `isValidPath`. +- Added `getQueryString`. +- Added `isValidQueryString`. +- Added `getFragment`. +- Added `isValidFragment`. + +## 2.2.0 (2018-10-29) + +### New Features + +- Added `getQueryArg`. +- Added `hasQueryArg`. +- Added `removeQueryArgs`. + +## 2.1.0 (2018-10-16) + +### New Feature + +- Added `safeDecodeURI`. + +## 2.0.1 (2018-09-30) + +### Bug Fix + +- Fix typo in the `qs` dependency definition in the `package.json` + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/url/README.md b/gb-src/packages/url/README.md new file mode 100644 index 0000000000000..5b381e074f48c --- /dev/null +++ b/gb-src/packages/url/README.md @@ -0,0 +1,394 @@ +# URL + +A collection of utilities to manipulate URLs. + +## Installation + +Install the module + +```bash +npm install @wordpress/url --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + + + +# **addQueryArgs** + +Appends arguments as querystring to the provided URL. If the URL already +includes query arguments, the arguments are merged with (and take precedent +over) the existing set. + +_Usage_ + +```js +const newURL = addQueryArgs( 'https://google.com', { q: 'test' } ); // https://google.com/?q=test +``` + +_Parameters_ + +- _url_ `[string]`: URL to which arguments should be appended. If omitted, only the resulting querystring is returned. +- _args_ `Object`: Query arguments to apply to URL. + +_Returns_ + +- `string`: URL with arguments applied. + +# **filterURLForDisplay** + +Returns a URL for display. + +_Usage_ + +```js +const displayUrl = filterURLForDisplay( 'https://www.wordpress.org/gutenberg/' ); // wordpress.org/gutenberg +``` + +_Parameters_ + +- _url_ `string`: Original URL. + +_Returns_ + +- `string`: Displayed URL. + +# **getAuthority** + +Returns the authority part of the URL. + +_Usage_ + +```js +const authority1 = getAuthority( 'https://wordpress.org/help/' ); // 'wordpress.org' +const authority2 = getAuthority( 'https://localhost:8080/test/' ); // 'localhost:8080' +``` + +_Parameters_ + +- _url_ `string`: The full URL. + +_Returns_ + +- `(string|void)`: The authority part of the URL. + +# **getFragment** + +Returns the fragment part of the URL. + +_Usage_ + +```js +const fragment1 = getFragment( 'http://localhost:8080/this/is/a/test?query=true#fragment' ); // '#fragment' +const fragment2 = getFragment( 'https://wordpress.org#another-fragment?query=true' ); // '#another-fragment' +``` + +_Parameters_ + +- _url_ `string`: The full URL + +_Returns_ + +- `(string|void)`: The fragment part of the URL. + +# **getPath** + +Returns the path part of the URL. + +_Usage_ + +```js +const path1 = getPath( 'http://localhost:8080/this/is/a/test?query=true' ); // 'this/is/a/test' +const path2 = getPath( 'https://wordpress.org/help/faq/' ); // 'help/faq' +``` + +_Parameters_ + +- _url_ `string`: The full URL. + +_Returns_ + +- `(string|void)`: The path part of the URL. + +# **getProtocol** + +Returns the protocol part of the URL. + +_Usage_ + +```js +const protocol1 = getProtocol( 'tel:012345678' ); // 'tel:' +const protocol2 = getProtocol( 'https://wordpress.org' ); // 'https:' +``` + +_Parameters_ + +- _url_ `string`: The full URL. + +_Returns_ + +- `(string|void)`: The protocol part of the URL. + +# **getQueryArg** + +Returns a single query argument of the url + +_Usage_ + +```js +const foo = getQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'foo' ); // bar +``` + +_Parameters_ + +- _url_ `string`: URL. +- _arg_ `string`: Query arg name. + +_Returns_ + +- `(QueryArgParsed|undefined)`: Query arg value. + +# **getQueryString** + +Returns the query string part of the URL. + +_Usage_ + +```js +const queryString1 = getQueryString( 'http://localhost:8080/this/is/a/test?query=true#fragment' ); // 'query=true' +const queryString2 = getQueryString( 'https://wordpress.org#fragment?query=false&search=hello' ); // 'query=false&search=hello' +``` + +_Parameters_ + +- _url_ `string`: The full URL. + +_Returns_ + +- `(string|void)`: The query string part of the URL. + +# **hasQueryArg** + +Determines whether the URL contains a given query arg. + +_Usage_ + +```js +const hasBar = hasQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'bar' ); // true +``` + +_Parameters_ + +- _url_ `string`: URL. +- _arg_ `string`: Query arg name. + +_Returns_ + +- `boolean`: Whether or not the URL contains the query arg. + +# **isEmail** + +Determines whether the given string looks like an email. + +_Usage_ + +```js +const isEmail = isEmail( 'hello@wordpress.org' ); // true +``` + +_Parameters_ + +- _email_ `string`: The string to scrutinise. + +_Returns_ + +- `boolean`: Whether or not it looks like an email. + +# **isURL** + +Determines whether the given string looks like a URL. + +_Usage_ + +```js +const isURL = isURL( 'https://wordpress.org' ); // true +``` + +_Parameters_ + +- _url_ `string`: The string to scrutinise. + +_Returns_ + +- `boolean`: Whether or not it looks like a URL. + +# **isValidAuthority** + +Checks for invalid characters within the provided authority. + +_Usage_ + +```js +const isValid = isValidAuthority( 'wordpress.org' ); // true +const isNotValid = isValidAuthority( 'wordpress#org' ); // false +``` + +_Parameters_ + +- _authority_ `string`: A string containing the URL authority. + +_Returns_ + +- `boolean`: True if the argument contains a valid authority. + +# **isValidFragment** + +Checks for invalid characters within the provided fragment. + +_Usage_ + +```js +const isValid = isValidFragment( '#valid-fragment' ); // true +const isNotValid = isValidFragment( '#invalid-#fragment' ); // false +``` + +_Parameters_ + +- _fragment_ `string`: The url fragment. + +_Returns_ + +- `boolean`: True if the argument contains a valid fragment. + +# **isValidPath** + +Checks for invalid characters within the provided path. + +_Usage_ + +```js +const isValid = isValidPath( 'test/path/' ); // true +const isNotValid = isValidPath( '/invalid?test/path/' ); // false +``` + +_Parameters_ + +- _path_ `string`: The URL path. + +_Returns_ + +- `boolean`: True if the argument contains a valid path + +# **isValidProtocol** + +Tests if a url protocol is valid. + +_Usage_ + +```js +const isValid = isValidProtocol( 'https:' ); // true +const isNotValid = isValidProtocol( 'https :' ); // false +``` + +_Parameters_ + +- _protocol_ `string`: The url protocol. + +_Returns_ + +- `boolean`: True if the argument is a valid protocol (e.g. http\:, tel:). + +# **isValidQueryString** + +Checks for invalid characters within the provided query string. + +_Usage_ + +```js +const isValid = isValidQueryString( 'query=true&another=false' ); // true +const isNotValid = isValidQueryString( 'query=true?another=false' ); // false +``` + +_Parameters_ + +- _queryString_ `string`: The query string. + +_Returns_ + +- `boolean`: True if the argument contains a valid query string. + +# **prependHTTP** + +Prepends "http\://" to a url, if it looks like something that is meant to be a TLD. + +_Usage_ + +```js +const actualURL = prependHTTP( 'wordpress.org' ); // http://wordpress.org +``` + +_Parameters_ + +- _url_ `string`: The URL to test. + +_Returns_ + +- `string`: The updated URL. + +# **removeQueryArgs** + +Removes arguments from the query string of the url + +_Usage_ + +```js +const newUrl = removeQueryArgs( 'https://wordpress.org?foo=bar&bar=baz&baz=foobar', 'foo', 'bar' ); // https://wordpress.org?baz=foobar +``` + +_Parameters_ + +- _url_ `string`: URL. +- _args_ `...string`: Query Args. + +_Returns_ + +- `string`: Updated URL. + +# **safeDecodeURI** + +Safely decodes a URI with `decodeURI`. Returns the URI unmodified if +`decodeURI` throws an error. + +_Usage_ + +```js +const badUri = safeDecodeURI( '%z' ); // does not throw an Error, simply returns '%z' +``` + +_Parameters_ + +- _uri_ `string`: URI to decode. + +_Returns_ + +- `string`: Decoded URI if possible. + +# **safeDecodeURIComponent** + +Safely decodes a URI component with `decodeURIComponent`. Returns the URI component unmodified if +`decodeURIComponent` throws an error. + +_Parameters_ + +- _uriComponent_ `string`: URI component to decode. + +_Returns_ + +- `string`: Decoded URI component if possible. + + + + +

    Code is Poetry.

    diff --git a/gb-src/packages/url/package.json b/gb-src/packages/url/package.json new file mode 100644 index 0000000000000..bc1e8bf3587eb --- /dev/null +++ b/gb-src/packages/url/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/url", + "version": "2.8.2", + "description": "WordPress URL utilities.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "url" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/url/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/url" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4", + "qs": "^6.5.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/url/src/index.js b/gb-src/packages/url/src/index.js new file mode 100644 index 0000000000000..2b549d6ba1dc4 --- /dev/null +++ b/gb-src/packages/url/src/index.js @@ -0,0 +1,434 @@ +/** + * External dependencies + */ +import { parse, stringify } from 'qs'; + +const URL_REGEXP = /^(?:https?:)?\/\/\S+$/i; +const EMAIL_REGEXP = /^(mailto:)?[a-z0-9._%+-]+@[a-z0-9][a-z0-9.-]*\.[a-z]{2,63}$/i; +const USABLE_HREF_REGEXP = /^(?:[a-z]+:|#|\?|\.|\/)/i; + +/** + * @typedef {{[key: string]: QueryArgParsed}} QueryArgObject + */ + +/** + * @typedef {string|string[]|QueryArgObject} QueryArgParsed + */ + +/** + * Determines whether the given string looks like a URL. + * + * @param {string} url The string to scrutinise. + * + * @example + * ```js + * const isURL = isURL( 'https://wordpress.org' ); // true + * ``` + * + * @return {boolean} Whether or not it looks like a URL. + */ +export function isURL( url ) { + return URL_REGEXP.test( url ); +} + +/** + * Determines whether the given string looks like an email. + * + * @param {string} email The string to scrutinise. + * + * @example + * ```js + * const isEmail = isEmail( 'hello@wordpress.org' ); // true + * ``` + * + * @return {boolean} Whether or not it looks like an email. + */ +export function isEmail( email ) { + return EMAIL_REGEXP.test( email ); +} + +/** + * Returns the protocol part of the URL. + * + * @param {string} url The full URL. + * + * @example + * ```js + * const protocol1 = getProtocol( 'tel:012345678' ); // 'tel:' + * const protocol2 = getProtocol( 'https://wordpress.org' ); // 'https:' + * ``` + * + * @return {string|void} The protocol part of the URL. + */ +export function getProtocol( url ) { + const matches = /^([^\s:]+:)/.exec( url ); + if ( matches ) { + return matches[ 1 ]; + } +} + +/** + * Tests if a url protocol is valid. + * + * @param {string} protocol The url protocol. + * + * @example + * ```js + * const isValid = isValidProtocol( 'https:' ); // true + * const isNotValid = isValidProtocol( 'https :' ); // false + * ``` + * + * @return {boolean} True if the argument is a valid protocol (e.g. http:, tel:). + */ +export function isValidProtocol( protocol ) { + if ( ! protocol ) { + return false; + } + return /^[a-z\-.\+]+[0-9]*:$/i.test( protocol ); +} + +/** + * Returns the authority part of the URL. + * + * @param {string} url The full URL. + * + * @example + * ```js + * const authority1 = getAuthority( 'https://wordpress.org/help/' ); // 'wordpress.org' + * const authority2 = getAuthority( 'https://localhost:8080/test/' ); // 'localhost:8080' + * ``` + * + * @return {string|void} The authority part of the URL. + */ +export function getAuthority( url ) { + const matches = /^[^\/\s:]+:(?:\/\/)?\/?([^\/\s#?]+)[\/#?]{0,1}\S*$/.exec( url ); + if ( matches ) { + return matches[ 1 ]; + } +} + +/** + * Checks for invalid characters within the provided authority. + * + * @param {string} authority A string containing the URL authority. + * + * @example + * ```js + * const isValid = isValidAuthority( 'wordpress.org' ); // true + * const isNotValid = isValidAuthority( 'wordpress#org' ); // false + * ``` + * + * @return {boolean} True if the argument contains a valid authority. + */ +export function isValidAuthority( authority ) { + if ( ! authority ) { + return false; + } + return /^[^\s#?]+$/.test( authority ); +} + +/** + * Returns the path part of the URL. + * + * @param {string} url The full URL. + * + * @example + * ```js + * const path1 = getPath( 'http://localhost:8080/this/is/a/test?query=true' ); // 'this/is/a/test' + * const path2 = getPath( 'https://wordpress.org/help/faq/' ); // 'help/faq' + * ``` + * + * @return {string|void} The path part of the URL. + */ +export function getPath( url ) { + const matches = /^[^\/\s:]+:(?:\/\/)?[^\/\s#?]+[\/]([^\s#?]+)[#?]{0,1}\S*$/.exec( url ); + if ( matches ) { + return matches[ 1 ]; + } +} + +/** + * Checks for invalid characters within the provided path. + * + * @param {string} path The URL path. + * + * @example + * ```js + * const isValid = isValidPath( 'test/path/' ); // true + * const isNotValid = isValidPath( '/invalid?test/path/' ); // false + * ``` + * + * @return {boolean} True if the argument contains a valid path + */ +export function isValidPath( path ) { + if ( ! path ) { + return false; + } + return /^[^\s#?]+$/.test( path ); +} + +/** + * Returns the query string part of the URL. + * + * @param {string} url The full URL. + * + * @example + * ```js + * const queryString1 = getQueryString( 'http://localhost:8080/this/is/a/test?query=true#fragment' ); // 'query=true' + * const queryString2 = getQueryString( 'https://wordpress.org#fragment?query=false&search=hello' ); // 'query=false&search=hello' + * ``` + * + * @return {string|void} The query string part of the URL. + */ +export function getQueryString( url ) { + const matches = /^\S+?\?([^\s#]+)/.exec( url ); + if ( matches ) { + return matches[ 1 ]; + } +} + +/** + * Checks for invalid characters within the provided query string. + * + * @param {string} queryString The query string. + * + * @example + * ```js + * const isValid = isValidQueryString( 'query=true&another=false' ); // true + * const isNotValid = isValidQueryString( 'query=true?another=false' ); // false + * ``` + * + * @return {boolean} True if the argument contains a valid query string. + */ +export function isValidQueryString( queryString ) { + if ( ! queryString ) { + return false; + } + return /^[^\s#?\/]+$/.test( queryString ); +} + +/** + * Returns the fragment part of the URL. + * + * @param {string} url The full URL + * + * @example + * ```js + * const fragment1 = getFragment( 'http://localhost:8080/this/is/a/test?query=true#fragment' ); // '#fragment' + * const fragment2 = getFragment( 'https://wordpress.org#another-fragment?query=true' ); // '#another-fragment' + * ``` + * + * @return {string|void} The fragment part of the URL. + */ +export function getFragment( url ) { + const matches = /^\S+?(#[^\s\?]*)/.exec( url ); + if ( matches ) { + return matches[ 1 ]; + } +} + +/** + * Checks for invalid characters within the provided fragment. + * + * @param {string} fragment The url fragment. + * + * @example + * ```js + * const isValid = isValidFragment( '#valid-fragment' ); // true + * const isNotValid = isValidFragment( '#invalid-#fragment' ); // false + * ``` + * + * @return {boolean} True if the argument contains a valid fragment. + */ +export function isValidFragment( fragment ) { + if ( ! fragment ) { + return false; + } + return /^#[^\s#?\/]*$/.test( fragment ); +} + +/** + * Appends arguments as querystring to the provided URL. If the URL already + * includes query arguments, the arguments are merged with (and take precedent + * over) the existing set. + * + * @param {string} [url=''] URL to which arguments should be appended. If omitted, + * only the resulting querystring is returned. + * @param {Object} args Query arguments to apply to URL. + * + * @example + * ```js + * const newURL = addQueryArgs( 'https://google.com', { q: 'test' } ); // https://google.com/?q=test + * ``` + * + * @return {string} URL with arguments applied. + */ +export function addQueryArgs( url = '', args ) { + // If no arguments are to be appended, return original URL. + if ( ! args || ! Object.keys( args ).length ) { + return url; + } + + let baseUrl = url; + + // Determine whether URL already had query arguments. + const queryStringIndex = url.indexOf( '?' ); + if ( queryStringIndex !== -1 ) { + // Merge into existing query arguments. + args = Object.assign( + parse( url.substr( queryStringIndex + 1 ) ), + args + ); + + // Change working base URL to omit previous query arguments. + baseUrl = baseUrl.substr( 0, queryStringIndex ); + } + + return baseUrl + '?' + stringify( args ); +} + +/** + * Returns a single query argument of the url + * + * @param {string} url URL. + * @param {string} arg Query arg name. + * + * @example + * ```js + * const foo = getQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'foo' ); // bar + * ``` + * + * @return {QueryArgParsed|undefined} Query arg value. + */ +export function getQueryArg( url, arg ) { + const queryStringIndex = url.indexOf( '?' ); + const query = queryStringIndex !== -1 ? parse( url.substr( queryStringIndex + 1 ) ) : {}; + + return query[ arg ]; +} + +/** + * Determines whether the URL contains a given query arg. + * + * @param {string} url URL. + * @param {string} arg Query arg name. + * + * @example + * ```js + * const hasBar = hasQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'bar' ); // true + * ``` + * + * @return {boolean} Whether or not the URL contains the query arg. + */ +export function hasQueryArg( url, arg ) { + return getQueryArg( url, arg ) !== undefined; +} + +/** + * Removes arguments from the query string of the url + * + * @param {string} url URL. + * @param {...string} args Query Args. + * + * @example + * ```js + * const newUrl = removeQueryArgs( 'https://wordpress.org?foo=bar&bar=baz&baz=foobar', 'foo', 'bar' ); // https://wordpress.org?baz=foobar + * ``` + * + * @return {string} Updated URL. + */ +export function removeQueryArgs( url, ...args ) { + const queryStringIndex = url.indexOf( '?' ); + const query = queryStringIndex !== -1 ? parse( url.substr( queryStringIndex + 1 ) ) : {}; + const baseUrl = queryStringIndex !== -1 ? url.substr( 0, queryStringIndex ) : url; + + args.forEach( ( arg ) => delete query[ arg ] ); + + return baseUrl + '?' + stringify( query ); +} + +/** + * Prepends "http://" to a url, if it looks like something that is meant to be a TLD. + * + * @param {string} url The URL to test. + * + * @example + * ```js + * const actualURL = prependHTTP( 'wordpress.org' ); // http://wordpress.org + * ``` + * + * @return {string} The updated URL. + */ +export function prependHTTP( url ) { + if ( ! url ) { + return url; + } + + url = url.trim(); + if ( ! USABLE_HREF_REGEXP.test( url ) && ! EMAIL_REGEXP.test( url ) ) { + return 'http://' + url; + } + + return url; +} + +/** + * Safely decodes a URI with `decodeURI`. Returns the URI unmodified if + * `decodeURI` throws an error. + * + * @param {string} uri URI to decode. + * + * @example + * ```js + * const badUri = safeDecodeURI( '%z' ); // does not throw an Error, simply returns '%z' + * ``` + * + * @return {string} Decoded URI if possible. + */ +export function safeDecodeURI( uri ) { + try { + return decodeURI( uri ); + } catch ( uriError ) { + return uri; + } +} + +/** + * Returns a URL for display. + * + * @param {string} url Original URL. + * + * @example + * ```js + * const displayUrl = filterURLForDisplay( 'https://www.wordpress.org/gutenberg/' ); // wordpress.org/gutenberg + * ``` + * + * @return {string} Displayed URL. + */ +export function filterURLForDisplay( url ) { + // Remove protocol and www prefixes. + const filteredURL = url.replace( /^(?:https?:)\/\/(?:www\.)?/, '' ); + + // Ends with / and only has that single slash, strip it. + if ( filteredURL.match( /^[^\/]+\/$/ ) ) { + return filteredURL.replace( '/', '' ); + } + + return filteredURL; +} + +/** + * Safely decodes a URI component with `decodeURIComponent`. Returns the URI component unmodified if + * `decodeURIComponent` throws an error. + * + * @param {string} uriComponent URI component to decode. + * + * @return {string} Decoded URI component if possible. + */ +export function safeDecodeURIComponent( uriComponent ) { + try { + return decodeURIComponent( uriComponent ); + } catch ( uriComponentError ) { + return uriComponent; + } +} diff --git a/gb-src/packages/url/src/test/index.test.js b/gb-src/packages/url/src/test/index.test.js new file mode 100644 index 0000000000000..31b7e05ecf3e5 --- /dev/null +++ b/gb-src/packages/url/src/test/index.test.js @@ -0,0 +1,552 @@ +/** + * External dependencies + */ +import { every } from 'lodash'; + +/** + * Internal dependencies + */ +import { + isURL, + isEmail, + getProtocol, + isValidProtocol, + getAuthority, + isValidAuthority, + getPath, + isValidPath, + getQueryString, + isValidQueryString, + getFragment, + isValidFragment, + addQueryArgs, + getQueryArg, + hasQueryArg, + removeQueryArgs, + prependHTTP, + safeDecodeURI, + filterURLForDisplay, +} from '../'; + +describe( 'isURL', () => { + it( 'returns true when given things that look like a URL', () => { + const urls = [ + 'http://wordpress.org', + 'https://wordpress.org', + 'HTTPS://WORDPRESS.ORG', + 'https://wordpress.org/foo#bar', + 'https://localhost/foo#bar', + ]; + + expect( every( urls, isURL ) ).toBe( true ); + } ); + + it( 'returns false when given things that don\'t look like a URL', () => { + const urls = [ + 'HTTP: HyperText Transfer Protocol', + 'URLs begin with a http:// prefix', + 'Go here: http://wordpress.org', + 'http://', + '', + ]; + + expect( every( urls, isURL ) ).toBe( false ); + } ); +} ); + +describe( 'isEmail', () => { + it( 'returns true when given things that look like an email', () => { + const emails = [ + 'simple@wordpress.org', + 'very.common@wordpress.org', + 'disposable.style.email.with+symbol@wordpress.org', + 'other.email-with-hyphen@wordpress.org', + 'fully-qualified-domain@wordpress.org', + 'user.name+tag+sorting@wordpress.org', + 'x@wordpress.org', + 'wordpress-indeed@strange-wordpress.org', + 'wordpress@s.wordpress', + ]; + + expect( every( emails, isEmail ) ).toBe( true ); + } ); + + it( 'returns false when given things that don\'t look like an email', () => { + const emails = [ + 'Abc.wordpress.org', + 'A@b@c@wordpress.org', + 'a"b(c)d,e:f;gi[j\k]l@wordpress.org', + 'just"not"right@wordpress.org', + 'this is"not\allowed@wordpress.org', + 'this\ still\"not\\allowed@wordpress.org', + '1234567890123456789012345678901234567890123456789012345678901234+x@wordpress.org', + ]; + + expect( every( emails, isEmail ) ).toBe( false ); + } ); +} ); + +describe( 'getProtocol', () => { + it( 'returns the protocol part of a URL', () => { + expect( getProtocol( 'http://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'http:' ); + expect( getProtocol( 'https://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'https:' ); + expect( getProtocol( 'https://wordpress.org#test' ) ).toBe( 'https:' ); + expect( getProtocol( 'https://wordpress.org/' ) ).toBe( 'https:' ); + expect( getProtocol( 'https://wordpress.org?test' ) ).toBe( 'https:' ); + expect( getProtocol( 'https://localhost:8080' ) ).toBe( 'https:' ); + expect( getProtocol( 'tel:1234' ) ).toBe( 'tel:' ); + expect( getProtocol( 'blob:data' ) ).toBe( 'blob:' ); + } ); + + it( 'returns undefined when the provided value does not contain a URL protocol', () => { + expect( getProtocol( '' ) ).toBeUndefined(); + expect( getProtocol( 'https' ) ).toBeUndefined(); + expect( getProtocol( 'test.com' ) ).toBeUndefined(); + expect( getProtocol( ' https:// ' ) ).toBeUndefined(); + } ); +} ); + +describe( 'isValidProtocol', () => { + it( 'returns true if the protocol is valid', () => { + expect( isValidProtocol( 'tel:' ) ).toBe( true ); + expect( isValidProtocol( 'http:' ) ).toBe( true ); + expect( isValidProtocol( 'https:' ) ).toBe( true ); + expect( isValidProtocol( 'file:' ) ).toBe( true ); + expect( isValidProtocol( 'test.protocol:' ) ).toBe( true ); + expect( isValidProtocol( 'test-protocol:' ) ).toBe( true ); + expect( isValidProtocol( 'test+protocol:' ) ).toBe( true ); + expect( isValidProtocol( 'test+protocol123:' ) ).toBe( true ); + } ); + + it( 'returns false if the protocol is invalid', () => { + expect( isValidProtocol() ).toBe( false ); + expect( isValidProtocol( '' ) ).toBe( false ); + expect( isValidProtocol( ' http: ' ) ).toBe( false ); + expect( isValidProtocol( 'http :' ) ).toBe( false ); + expect( isValidProtocol( 'http: //' ) ).toBe( false ); + expect( isValidProtocol( 'test protocol:' ) ).toBe( false ); + expect( isValidProtocol( 'test#protocol:' ) ).toBe( false ); + expect( isValidProtocol( 'test?protocol:' ) ).toBe( false ); + expect( isValidProtocol( '123test+protocol:' ) ).toBe( false ); + } ); +} ); + +describe( 'getAuthority', () => { + it( 'returns the authority part of a URL', () => { + expect( getAuthority( 'https://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'user:password@www.test-this.com:1020' ); + expect( getAuthority( 'http://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'user:password@www.test-this.com:1020' ); + expect( getAuthority( 'https://wordpress.org#test' ) ).toBe( 'wordpress.org' ); + expect( getAuthority( 'https://wordpress.org/' ) ).toBe( 'wordpress.org' ); + expect( getAuthority( 'https://wordpress.org?test' ) ).toBe( 'wordpress.org' ); + expect( getAuthority( 'https://localhost:8080' ) ).toBe( 'localhost:8080' ); + } ); + + it( 'returns undefined when the provided value does not contain a URL authority', () => { + expect( getAuthority( '' ) ).toBeUndefined(); + expect( getAuthority( 'https://' ) ).toBeUndefined(); + expect( getAuthority( 'https:///' ) ).toBeUndefined(); + expect( getAuthority( 'https://#' ) ).toBeUndefined(); + expect( getAuthority( 'https://?' ) ).toBeUndefined(); + expect( getAuthority( 'test.com' ) ).toBeUndefined(); + expect( getAuthority( 'https://#?hello' ) ).toBeUndefined(); + } ); +} ); + +describe( 'isValidAuthority', () => { + it( 'returns true if the authority is valid', () => { + expect( isValidAuthority( 'user:password@www.test-this.com:1020' ) ).toBe( true ); + expect( isValidAuthority( 'wordpress.org' ) ).toBe( true ); + expect( isValidAuthority( 'localhost' ) ).toBe( true ); + expect( isValidAuthority( 'localhost:8080' ) ).toBe( true ); + expect( isValidAuthority( 'www.the-best-website.co.uk' ) ).toBe( true ); + expect( isValidAuthority( 'WWW.VERYLOUD.COM' ) ).toBe( true ); + } ); + + it( 'returns false if the authority is invalid', () => { + expect( isValidAuthority() ).toBe( false ); + expect( isValidAuthority( '' ) ).toBe( false ); + expect( isValidAuthority( 'inv alid.website.com' ) ).toBe( false ); + expect( isValidAuthority( 'test#.com' ) ).toBe( false ); + expect( isValidAuthority( 'test?.com' ) ).toBe( false ); + } ); +} ); + +describe( 'getPath', () => { + it( 'returns the path part of a URL', () => { + expect( getPath( 'https://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'test-path/file.extension' ); + expect( getPath( 'http://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'test-path/file.extension' ); + expect( getPath( 'https://wordpress.org/test-path#anchor' ) ).toBe( 'test-path' ); + expect( getPath( 'https://wordpress.org/test-path?query' ) ).toBe( 'test-path' ); + expect( getPath( 'https://www.google.com/search?source=hp&ei=tP7kW8-_FoK89QORoa2QBQ&q=test+url&oq=test+url&gs_l=psy-ab.3..0l10' ) ).toBe( 'search' ); + expect( getPath( 'https://wordpress.org/this%20is%20a%20test' ) ).toBe( 'this%20is%20a%20test' ); + expect( getPath( 'https://wordpress.org/this%20is%20a%20test?query' ) ).toBe( 'this%20is%20a%20test' ); + } ); + + it( 'returns undefined when the provided value does not contain a URL path', () => { + expect( getPath() ).toBeUndefined(); + expect( getPath( '' ) ).toBeUndefined(); + expect( getPath( 'https://wordpress.org#test' ) ).toBeUndefined(); + expect( getPath( 'https://wordpress.org/' ) ).toBeUndefined(); + expect( getPath( 'https://wordpress.org?test' ) ).toBeUndefined(); + expect( getPath( 'https://localhost:8080' ) ).toBeUndefined(); + expect( getPath( 'https://' ) ).toBeUndefined(); + expect( getPath( 'https:///test' ) ).toBeUndefined(); + expect( getPath( 'https://#' ) ).toBeUndefined(); + expect( getPath( 'https://?' ) ).toBeUndefined(); + expect( getPath( 'test.com' ) ).toBeUndefined(); + expect( getPath( 'https://#?hello' ) ).toBeUndefined(); + expect( getPath( 'https' ) ).toBeUndefined(); + } ); +} ); + +describe( 'isValidPath', () => { + it( 'returns true if the path is valid', () => { + expect( isValidPath( 'test-path/file.extension' ) ).toBe( true ); + expect( isValidPath( '/absolute/path' ) ).toBe( true ); + expect( isValidPath( 'relative/path' ) ).toBe( true ); + expect( isValidPath( 'slightly/longer/path/' ) ).toBe( true ); + expect( isValidPath( 'path/with/percent%20encoding' ) ).toBe( true ); + } ); + + it( 'returns false if the path is invalid', () => { + expect( isValidPath() ).toBe( false ); + expect( isValidPath( '' ) ).toBe( false ); + expect( isValidPath( 'path /with/spaces' ) ).toBe( false ); + expect( isValidPath( 'path/with/number/symbol#' ) ).toBe( false ); + expect( isValidPath( 'path/with/question/mark?' ) ).toBe( false ); + expect( isValidPath( ' path/with/padding ' ) ).toBe( false ); + } ); +} ); + +describe( 'getQueryString', () => { + it( 'returns the query string of a URL', () => { + expect( getQueryString( 'https://user:password@www.test-this.com:1020/test-path/file.extension#anchor?query=params&more' ) ).toBe( 'query=params&more' ); + expect( getQueryString( 'http://user:password@www.test-this.com:1020/test-path/file.extension?query=params&more#anchor' ) ).toBe( 'query=params&more' ); + expect( getQueryString( 'https://wordpress.org/test-path?query' ) ).toBe( 'query' ); + expect( getQueryString( 'https://www.google.com/search?source=hp&ei=tP7kW8-_FoK89QORoa2QBQ&q=test+url&oq=test+url&gs_l=psy-ab.3..0l10' ) ) + .toBe( 'source=hp&ei=tP7kW8-_FoK89QORoa2QBQ&q=test+url&oq=test+url&gs_l=psy-ab.3..0l10' ); + expect( getQueryString( 'https://wordpress.org/this%20is%20a%20test?query' ) ).toBe( 'query' ); + expect( getQueryString( 'https://wordpress.org/test?query=something%20with%20spaces' ) ).toBe( 'query=something%20with%20spaces' ); + expect( getQueryString( 'https://andalouses.example/beach?foo[]=bar&foo[]=baz' ) ).toBe( 'foo[]=bar&foo[]=baz' ); + expect( getQueryString( 'test.com?foo[]=bar&foo[]=baz' ) ).toBe( 'foo[]=bar&foo[]=baz' ); + expect( getQueryString( 'test.com?foo=bar&foo=baz?test' ) ).toBe( 'foo=bar&foo=baz?test' ); + } ); + + it( 'returns undefined when the provided does not contain a url query string', () => { + expect( getQueryString( '' ) ).toBeUndefined(); + expect( getQueryString( 'https://wordpress.org/test-path#anchor' ) ).toBeUndefined(); + expect( getQueryString( 'https://wordpress.org/this%20is%20a%20test' ) ).toBeUndefined(); + expect( getQueryString( 'https://wordpress.org#test' ) ).toBeUndefined(); + expect( getQueryString( 'https://wordpress.org/' ) ).toBeUndefined(); + expect( getQueryString( 'https://localhost:8080' ) ).toBeUndefined(); + expect( getQueryString( 'https://' ) ).toBeUndefined(); + expect( getQueryString( 'https:///test' ) ).toBeUndefined(); + expect( getQueryString( 'https://#' ) ).toBeUndefined(); + expect( getQueryString( 'https://?' ) ).toBeUndefined(); + expect( getQueryString( 'test.com' ) ).toBeUndefined(); + } ); +} ); + +describe( 'isValidQueryString', () => { + it( 'returns true if the query string is valid', () => { + expect( isValidQueryString( 'test' ) ).toBe( true ); + expect( isValidQueryString( 'test=true' ) ).toBe( true ); + expect( isValidQueryString( 'test=true&another' ) ).toBe( true ); + expect( isValidQueryString( 'test=true&another=false' ) ).toBe( true ); + expect( isValidQueryString( 'test[]=true&another[]=false' ) ).toBe( true ); + expect( isValidQueryString( 'query=something%20with%20spaces' ) ).toBe( true ); + expect( isValidQueryString( 'source=hp&ei=tP7kW8-_FoK89QORoa2QBQ&q=test+url&oq=test+url&gs_l=psy-ab.3..0l10' ) ).toBe( true ); + } ); + + it( 'returns false if the query string is invalid', () => { + expect( isValidQueryString() ).toBe( false ); + expect( isValidQueryString( '' ) ).toBe( false ); + expect( isValidQueryString( '?test=false' ) ).toBe( false ); + expect( isValidQueryString( ' test=false ' ) ).toBe( false ); + expect( isValidQueryString( 'test = false' ) ).toBe( false ); + expect( isValidQueryString( 'test=f?alse' ) ).toBe( false ); + expect( isValidQueryString( 'test=f#alse' ) ).toBe( false ); + expect( isValidQueryString( 'test=f/alse' ) ).toBe( false ); + expect( isValidQueryString( 'test=f?alse' ) ).toBe( false ); + expect( isValidQueryString( '/test=false' ) ).toBe( false ); + expect( isValidQueryString( 'test=false/' ) ).toBe( false ); + } ); +} ); + +describe( 'getFragment', () => { + it( 'returns the fragment of a URL', () => { + expect( getFragment( 'https://user:password@www.test-this.com:1020/test-path/file.extension#fragment?query=params&more' ) ).toBe( '#fragment' ); + expect( getFragment( 'http://user:password@www.test-this.com:1020/test-path/file.extension?query=params&more#fragment' ) ).toBe( '#fragment' ); + expect( getFragment( 'relative/url/#fragment' ) ).toBe( '#fragment' ); + expect( getFragment( '/absolute/url/#fragment' ) ).toBe( '#fragment' ); + } ); + + it( 'returns undefined when the provided does not contain a url fragment', () => { + expect( getFragment( '' ) ).toBeUndefined(); + expect( getFragment( 'https://wordpress.org/test-path?query' ) ).toBeUndefined(); + expect( getFragment( 'https://wordpress.org/test-path' ) ).toBeUndefined(); + expect( getFragment( 'https://wordpress.org/this%20is%20a%20test' ) ).toBeUndefined(); + expect( getFragment( 'https://www.google.com/search?source=hp&ei=tP7kW8-_FoK89QORoa2QBQ&q=test+url&oq=test+url&gs_l=psy-ab.3..0l10' ) ).toBeUndefined(); + expect( getFragment( 'https://wordpress.org' ) ).toBeUndefined(); + expect( getFragment( 'https://localhost:8080' ) ).toBeUndefined(); + expect( getFragment( 'https://' ) ).toBeUndefined(); + expect( getFragment( 'https:///test' ) ).toBeUndefined(); + expect( getFragment( 'https://?' ) ).toBeUndefined(); + expect( getFragment( 'test.com' ) ).toBeUndefined(); + } ); +} ); + +describe( 'isValidFragment', () => { + it( 'returns true if the fragment is valid', () => { + expect( isValidFragment( '#' ) ).toBe( true ); + expect( isValidFragment( '#yesitis' ) ).toBe( true ); + expect( isValidFragment( '#yes_it_is' ) ).toBe( true ); + expect( isValidFragment( '#yes~it~is' ) ).toBe( true ); + expect( isValidFragment( '#yes-it-is' ) ).toBe( true ); + } ); + + it( 'returns false if the fragment is invalid', () => { + expect( isValidFragment( '' ) ).toBe( false ); + expect( isValidFragment( ' #no-it-isnt ' ) ).toBe( false ); + expect( isValidFragment( '#no-it-isnt#' ) ).toBe( false ); + expect( isValidFragment( '#no-it-#isnt' ) ).toBe( false ); + expect( isValidFragment( '#no-it-isnt?' ) ).toBe( false ); + expect( isValidFragment( '#no-it isnt' ) ).toBe( false ); + expect( isValidFragment( '/#no-it-isnt' ) ).toBe( false ); + expect( isValidFragment( '#no-it-isnt/' ) ).toBe( false ); + } ); +} ); + +describe( 'addQueryArgs', () => { + it( 'should append args to a URL without query string', () => { + const url = 'https://andalouses.example/beach'; + const args = { sun: 'true', sand: 'false' }; + + expect( addQueryArgs( url, args ) ).toBe( 'https://andalouses.example/beach?sun=true&sand=false' ); + } ); + + it( 'should append args to a URL with query string', () => { + const url = 'https://andalouses.example/beach?night=false'; + const args = { sun: 'true', sand: 'false' }; + + expect( addQueryArgs( url, args ) ).toBe( 'https://andalouses.example/beach?night=false&sun=true&sand=false' ); + } ); + + it( 'should update args to an URL with conflicting query string', () => { + const url = 'https://andalouses.example/beach?night=false&sun=false&sand=true'; + const args = { sun: 'true', sand: 'false' }; + + expect( addQueryArgs( url, args ) ).toBe( 'https://andalouses.example/beach?night=false&sun=true&sand=false' ); + } ); + + it( 'should update args to an URL with array parameters', () => { + const url = 'https://andalouses.example/beach?time[]=10&time[]=11'; + const args = { beach: [ 'sand', 'rock' ] }; + + expect( safeDecodeURI( addQueryArgs( url, args ) ) ).toBe( 'https://andalouses.example/beach?time[0]=10&time[1]=11&beach[0]=sand&beach[1]=rock' ); + } ); + + it( 'should disregard keys with undefined values', () => { + const url = 'https://andalouses.example/beach'; + const args = { sun: 'true', sand: undefined }; + + expect( addQueryArgs( url, args ) ).toBe( 'https://andalouses.example/beach?sun=true' ); + } ); + + it( 'should encodes spaces by RFC 3986', () => { + const url = 'https://andalouses.example/beach'; + const args = { activity: 'fun in the sun' }; + + expect( addQueryArgs( url, args ) ).toBe( 'https://andalouses.example/beach?activity=fun%20in%20the%20sun' ); + } ); + + it( 'should return only querystring when passed undefined url', () => { + const url = undefined; + const args = { sun: 'true' }; + + expect( addQueryArgs( url, args ) ).toBe( '?sun=true' ); + } ); + + it( 'should return URL argument unaffected if no query arguments to append', () => { + [ '', 'https://example.com', 'https://example.com?' ].forEach( ( url ) => { + [ undefined, {} ].forEach( ( args ) => { + expect( addQueryArgs( url, args ) ).toBe( url ); + } ); + } ); + } ); +} ); + +describe( 'getQueryArg', () => { + it( 'should get the value of an existing query arg', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; + + expect( getQueryArg( url, 'foo' ) ).toBe( 'bar' ); + } ); + + it( 'should not return a value of an unknown query arg', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; + + expect( getQueryArg( url, 'baz' ) ).toBeUndefined(); + } ); + + it( 'should get the value of an arry query arg', () => { + const url = 'https://andalouses.example/beach?foo[]=bar&foo[]=baz'; + + expect( getQueryArg( url, 'foo' ) ).toEqual( [ 'bar', 'baz' ] ); + } ); + + it( 'continues to work when an anchor follows the query string', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz#foo'; + + expect( getQueryArg( url, 'foo' ) ).toEqual( 'bar' ); + } ); +} ); + +describe( 'hasQueryArg', () => { + it( 'should return true for an existing query arg', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; + + expect( hasQueryArg( url, 'foo' ) ).toBeTruthy(); + } ); + + it( 'should return false for an unknown query arg', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; + + expect( hasQueryArg( url, 'baz' ) ).toBeFalsy(); + } ); + + it( 'should return true for an arry query arg', () => { + const url = 'https://andalouses.example/beach?foo[]=bar&foo[]=baz'; + + expect( hasQueryArg( url, 'foo' ) ).toBeTruthy(); + } ); +} ); + +describe( 'removeQueryArgs', () => { + it( 'should not change URL not containing query args', () => { + const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; + + expect( removeQueryArgs( url, 'baz', 'test' ) ).toEqual( url ); + } ); + + it( 'should remove existing query args', () => { + const url = 'https://andalouses.example/beach?foo=bar&baz=foo&bar=baz'; + + expect( removeQueryArgs( url, 'foo', 'bar' ) ).toEqual( 'https://andalouses.example/beach?baz=foo' ); + } ); + + it( 'should remove array query arg', () => { + const url = 'https://andalouses.example/beach?foo[]=bar&foo[]=baz&bar=foobar'; + + expect( removeQueryArgs( url, 'foo' ) ).toEqual( 'https://andalouses.example/beach?bar=foobar' ); + } ); +} ); + +describe( 'prependHTTP', () => { + it( 'should prepend http to a domain', () => { + const url = 'wordpress.org'; + + expect( prependHTTP( url ) ).toBe( 'http://' + url ); + } ); + + it( 'shouldn’t prepend http to an email', () => { + const url = 'foo@wordpress.org'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to an absolute URL', () => { + const url = '/wordpress'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to a relative URL', () => { + const url = './wordpress'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to an anchor URL', () => { + const url = '#wordpress'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to a URL that already has http', () => { + const url = 'http://wordpress.org'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to a URL that already has https', () => { + const url = 'https://wordpress.org'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to a URL that already has ftp', () => { + const url = 'ftp://wordpress.org'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'shouldn’t prepend http to a URL that already has mailto', () => { + const url = 'mailto:foo@wordpress.org'; + + expect( prependHTTP( url ) ).toBe( url ); + } ); + + it( 'should remove leading whitespace before prepending HTTP', () => { + const url = ' wordpress.org'; + + expect( prependHTTP( url ) ).toBe( 'http://wordpress.org' ); + } ); + + it( 'should not have trailing whitespaces', () => { + const url = 'wordpress.org '; + + expect( prependHTTP( url ) ).toBe( 'http://wordpress.org' ); + } ); +} ); + +describe( 'safeDecodeURI', () => { + it( 'should decode URI if formed well', () => { + const encoded = 'https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B'; + const decoded = 'https://mozilla.org/?x=шеллы'; + + expect( safeDecodeURI( encoded ) ).toBe( decoded ); + } ); + + it( 'should return URI if malformed', () => { + const malformed = '%1'; + + expect( safeDecodeURI( malformed ) ).toBe( malformed ); + } ); +} ); + +describe( 'filterURLForDisplay', () => { + it( 'should remove protocol', () => { + let url = filterURLForDisplay( 'http://wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + url = filterURLForDisplay( 'https://wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should remove www subdomain', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should remove single trailing slash', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/' ); + expect( url ).toBe( 'wordpress.org' ); + } ); + it( 'should preserve slashes where the url has multiple in the path', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/something/' ); + expect( url ).toBe( 'wordpress.org/something/' ); + } ); + it( 'should preserve slash where the url has path after the initial slash', () => { + const url = filterURLForDisplay( 'http://www.wordpress.org/something' ); + expect( url ).toBe( 'wordpress.org/something' ); + } ); +} ); + diff --git a/gb-src/packages/viewport/.npmrc b/gb-src/packages/viewport/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/viewport/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/viewport/CHANGELOG.md b/gb-src/packages/viewport/CHANGELOG.md new file mode 100644 index 0000000000000..e3a0fbd033c08 --- /dev/null +++ b/gb-src/packages/viewport/CHANGELOG.md @@ -0,0 +1,29 @@ +## 2.1.0 (2019-01-03) + +### Improvements + +- The initial viewport state is assigned synchronously, rather than on the next process tick. This should have an impact of fewer callbacks made to data subscribers. + +## 2.0.13 (2018-12-12) + +## 2.0.12 (2018-11-20) + +## 2.0.11 (2018-11-15) + +## 2.0.10 (2018-11-09) + +## 2.0.9 (2018-11-09) + +## 2.0.8 (2018-11-03) + +## 2.0.7 (2018-10-30) + +## 2.0.5 (2018-10-19) + +## 2.0.4 (2018-10-18) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/gb-src/packages/viewport/README.md b/gb-src/packages/viewport/README.md new file mode 100644 index 0000000000000..320f46daf8d43 --- /dev/null +++ b/gb-src/packages/viewport/README.md @@ -0,0 +1,112 @@ +# Viewport + +Viewport is a module for responding to changes in the browser viewport size. It registers its own [data module](/packages/data/README.md), updated in response to browser media queries on a standard set of supported breakpoints. This data and the included higher-order components can be used in your own modules and components to implement viewport-dependent behaviors. + +## Installation + +Install the module + +```bash +npm install @wordpress/viewport --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +The standard set of breakpoint thresholds is as follows: + +| Name | Pixel Width | +| -------- | ----------- | +| `huge` | 1440 | +| `wide` | 1280 | +| `large` | 960 | +| `medium` | 782 | +| `small` | 600 | +| `mobile` | 480 | + +### Data Module + +The Viewport module registers itself under the `core/viewport` data namespace. + +```js +const isSmall = select( 'core/viewport' ).isViewportMatch( '< medium' ); +``` + +The `isViewportMatch` selector accepts a single string argument `query`. It consists of an optional operator and breakpoint name, separated with a space. The operator can be `<` or `>=`, defaulting to `>=`. + +```js +const { isViewportMatch } = select( 'core/viewport' ); +const isSmall = isViewportMatch( '< medium' ); +const isWideOrHuge = isViewportMatch( '>= wide' ); +// Equivalent: +// const isWideOrHuge = isViewportMatch( 'wide' ); +``` + +### Higher-Order Components + +This package provides a set of HOCs to author components whose behavior should vary depending on the viewport. + + + +# **ifViewportMatches** + +Higher-order component creator, creating a new component which renders if +the viewport query is satisfied. + +_Related_ + +- withViewportMatches + +_Usage_ + +```jsx +function MyMobileComponent() { + return
    I'm only rendered on mobile viewports!
    ; +} + +MyMobileComponent = ifViewportMatches( '< small' )( MyMobileComponent ); +``` + +_Parameters_ + +- _query_ `string`: Viewport query. + +_Returns_ + +- `Function`: Higher-order component. + +# **withViewportMatch** + +Higher-order component creator, creating a new component which renders with +the given prop names, where the value passed to the underlying component is +the result of the query assigned as the object's value. + +_Related_ + +- isViewportMatch + +_Usage_ + +```jsx +function MyComponent( { isMobile } ) { + return ( +
    Currently: { isMobile ? 'Mobile' : 'Not Mobile' }
    + ); +} + +MyComponent = withViewportMatch( { isMobile: '< small' } )( MyComponent ); +``` + +_Parameters_ + +- _queries_ `Object`: Object of prop name to viewport query. + +_Returns_ + +- `Function`: Higher-order component. + + + + +

    Code is Poetry.

    diff --git a/gb-src/packages/viewport/package.json b/gb-src/packages/viewport/package.json new file mode 100644 index 0000000000000..5cedb8e675caa --- /dev/null +++ b/gb-src/packages/viewport/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wordpress/viewport", + "version": "2.8.2", + "description": "Viewport module for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "viewport" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/viewport/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/viewport" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/viewport/src/if-viewport-matches.js b/gb-src/packages/viewport/src/if-viewport-matches.js new file mode 100644 index 0000000000000..76ea3c5650b56 --- /dev/null +++ b/gb-src/packages/viewport/src/if-viewport-matches.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { ifCondition, compose, createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import withViewportMatch from './with-viewport-match'; + +/** + * Higher-order component creator, creating a new component which renders if + * the viewport query is satisfied. + * + * @see withViewportMatches + * + * @param {string} query Viewport query. + * + * @example + * + * ```jsx + * function MyMobileComponent() { + * return
    I'm only rendered on mobile viewports!
    ; + * } + * + * MyMobileComponent = ifViewportMatches( '< small' )( MyMobileComponent ); + * ``` + * + * @return {Function} Higher-order component. + */ +const ifViewportMatches = ( query ) => createHigherOrderComponent( + compose( [ + withViewportMatch( { + isViewportMatch: query, + } ), + ifCondition( ( props ) => props.isViewportMatch ), + ] ), + 'ifViewportMatches' +); + +export default ifViewportMatches; diff --git a/gb-src/packages/viewport/src/index.js b/gb-src/packages/viewport/src/index.js new file mode 100644 index 0000000000000..bd65fd4e6da09 --- /dev/null +++ b/gb-src/packages/viewport/src/index.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { reduce, forEach, debounce, mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './store'; + +export { default as ifViewportMatches } from './if-viewport-matches'; +export { default as withViewportMatch } from './with-viewport-match'; + +/** + * Hash of breakpoint names with pixel width at which it becomes effective. + * + * @see _breakpoints.scss + * + * @type {Object} + */ +const BREAKPOINTS = { + huge: 1440, + wide: 1280, + large: 960, + medium: 782, + small: 600, + mobile: 480, +}; + +/** + * Hash of query operators with corresponding condition for media query. + * + * @type {Object} + */ +const OPERATORS = { + '<': 'max-width', + '>=': 'min-width', +}; + +/** + * Callback invoked when media query state should be updated. Is invoked a + * maximum of one time per call stack. + */ +const setIsMatching = debounce( () => { + const values = mapValues( queries, ( query ) => query.matches ); + dispatch( 'core/viewport' ).setIsMatching( values ); +}, { leading: true } ); + +/** + * Hash of breakpoint names with generated MediaQueryList for corresponding + * media query. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList + * + * @type {Object} + */ +const queries = reduce( BREAKPOINTS, ( result, width, name ) => { + forEach( OPERATORS, ( condition, operator ) => { + const list = window.matchMedia( `(${ condition }: ${ width }px)` ); + list.addListener( setIsMatching ); + + const key = [ operator, name ].join( ' ' ); + result[ key ] = list; + } ); + + return result; +}, {} ); + +window.addEventListener( 'orientationchange', setIsMatching ); + +// Set initial values +setIsMatching(); +setIsMatching.flush(); diff --git a/gb-src/packages/viewport/src/index.native.js b/gb-src/packages/viewport/src/index.native.js new file mode 100644 index 0000000000000..605b511ac9d68 --- /dev/null +++ b/gb-src/packages/viewport/src/index.native.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import './store'; + +export { default as ifViewportMatches } from './if-viewport-matches'; +export { default as withViewportMatch } from './with-viewport-match'; + diff --git a/gb-src/packages/viewport/src/store/actions.js b/gb-src/packages/viewport/src/store/actions.js new file mode 100644 index 0000000000000..0da1576677c0e --- /dev/null +++ b/gb-src/packages/viewport/src/store/actions.js @@ -0,0 +1,15 @@ +/** + * Returns an action object used in signalling that viewport queries have been + * updated. Values are specified as an object of breakpoint query keys where + * value represents whether query matches. + * + * @param {Object} values Breakpoint query matches. + * + * @return {Object} Action object. + */ +export function setIsMatching( values ) { + return { + type: 'SET_IS_MATCHING', + values, + }; +} diff --git a/gb-src/packages/viewport/src/store/index.js b/gb-src/packages/viewport/src/store/index.js new file mode 100644 index 0000000000000..88c7cba476165 --- /dev/null +++ b/gb-src/packages/viewport/src/store/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +export default registerStore( 'core/viewport', { + reducer, + actions, + selectors, +} ); diff --git a/gb-src/packages/viewport/src/store/reducer.js b/gb-src/packages/viewport/src/store/reducer.js new file mode 100644 index 0000000000000..22efbda63191e --- /dev/null +++ b/gb-src/packages/viewport/src/store/reducer.js @@ -0,0 +1,19 @@ +/** + * Reducer returning the viewport state, as keys of breakpoint queries with + * boolean value representing whether query is matched. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +function reducer( state = {}, action ) { + switch ( action.type ) { + case 'SET_IS_MATCHING': + return action.values; + } + + return state; +} + +export default reducer; diff --git a/gb-src/packages/viewport/src/store/selectors.js b/gb-src/packages/viewport/src/store/selectors.js new file mode 100644 index 0000000000000..d379beeb5052f --- /dev/null +++ b/gb-src/packages/viewport/src/store/selectors.js @@ -0,0 +1,24 @@ +/** + * Returns true if the viewport matches the given query, or false otherwise. + * + * @param {Object} state Viewport state object. + * @param {string} query Query string. Includes operator and breakpoint name, + * space separated. Operator defaults to >=. + * + * @example + * + * ```js + * isViewportMatch( state, '< huge' ); + * isViewPortMatch( state, 'medium' ); + * ``` + * + * @return {boolean} Whether viewport matches query. + */ +export function isViewportMatch( state, query ) { + // Default to `>=` if no operator is present. + if ( query.indexOf( ' ' ) === -1 ) { + query = '>= ' + query; + } + + return !! state[ query ]; +} diff --git a/gb-src/packages/viewport/src/store/test/reducer.js b/gb-src/packages/viewport/src/store/test/reducer.js new file mode 100644 index 0000000000000..ffec6ca706c31 --- /dev/null +++ b/gb-src/packages/viewport/src/store/test/reducer.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer from '../reducer'; + +describe( 'reducer', () => { + it( 'defaults to an empty object', () => { + const state = reducer( undefined, {} ); + + expect( state ).toEqual( {} ); + } ); + + it( 'replaces its state in response to new matching values', () => { + const original = deepFreeze( reducer( undefined, {} ) ); + const state = reducer( original, { + type: 'SET_IS_MATCHING', + values: { + huge: true, + }, + } ); + + expect( state ).toEqual( { + huge: true, + } ); + } ); +} ); diff --git a/gb-src/packages/viewport/src/store/test/selectors.js b/gb-src/packages/viewport/src/store/test/selectors.js new file mode 100644 index 0000000000000..d05406ddfe679 --- /dev/null +++ b/gb-src/packages/viewport/src/store/test/selectors.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { isViewportMatch } from '../selectors'; + +describe( 'selectors', () => { + describe( 'isViewportMatch()', () => { + it( 'should return with omitted operator defaulting to >=', () => { + const result = isViewportMatch( { + '>= wide': true, + '< wide': false, + }, 'wide' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return with known query value', () => { + const result = isViewportMatch( { + '>= wide': false, + '< wide': true, + }, '< wide' ); + + expect( result ).toBe( true ); + } ); + } ); +} ); diff --git a/gb-src/packages/viewport/src/test/if-viewport-matches.js b/gb-src/packages/viewport/src/test/if-viewport-matches.js new file mode 100644 index 0000000000000..4a57039e9c7f1 --- /dev/null +++ b/gb-src/packages/viewport/src/test/if-viewport-matches.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import TestRenderer, { act } from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import '../store'; +import ifViewportMatches from '../if-viewport-matches'; + +describe( 'ifViewportMatches()', () => { + const Component = () =>
    Hello
    ; + + it( 'should not render if query does not match', () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': false } ); + const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); + + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( ); + } ); + + expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 0 ); + } ); + + it( 'should render if query does match', () => { + act( () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + } ); + const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( ); + } ); + expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 1 ); + } ); +} ); diff --git a/gb-src/packages/viewport/src/test/with-viewport-match.js b/gb-src/packages/viewport/src/test/with-viewport-match.js new file mode 100644 index 0000000000000..8744178e2c210 --- /dev/null +++ b/gb-src/packages/viewport/src/test/with-viewport-match.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import renderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import '../store'; +import withViewportMatch from '../with-viewport-match'; + +describe( 'withViewportMatch()', () => { + const ChildComponent = () =>
    Hello
    ; + + // this is needed because TestUtils does not accept a stateless component. + // anything run through a HOC ends up as a stateless component. + const getTestComponent = ( WrappedComponent ) => { + class TestComponent extends Component { + render() { + return ; + } + } + return ; + }; + + it( 'should render with result of query as custom prop name', () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + const EnhancedComponent = withViewportMatch( + { isWide: '> wide' } + )( ChildComponent ); + const wrapper = renderer.create( getTestComponent( EnhancedComponent ) ); + + expect( wrapper.root.findByType( ChildComponent ).props.isWide ) + .toBe( true ); + + wrapper.unmount(); + } ); +} ); diff --git a/gb-src/packages/viewport/src/with-viewport-match.js b/gb-src/packages/viewport/src/with-viewport-match.js new file mode 100644 index 0000000000000..3398fda7e4f3f --- /dev/null +++ b/gb-src/packages/viewport/src/with-viewport-match.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; + +/** + * Higher-order component creator, creating a new component which renders with + * the given prop names, where the value passed to the underlying component is + * the result of the query assigned as the object's value. + * + * @see isViewportMatch + * + * @param {Object} queries Object of prop name to viewport query. + * + * @example + * + * ```jsx + * function MyComponent( { isMobile } ) { + * return ( + *
    Currently: { isMobile ? 'Mobile' : 'Not Mobile' }
    + * ); + * } + * + * MyComponent = withViewportMatch( { isMobile: '< small' } )( MyComponent ); + * ``` + * + * @return {Function} Higher-order component. + */ +const withViewportMatch = ( queries ) => createHigherOrderComponent( + withSelect( ( select ) => { + return mapValues( queries, ( query ) => { + return select( 'core/viewport' ).isViewportMatch( query ); + } ); + } ), + 'withViewportMatch' +); + +export default withViewportMatch; diff --git a/gb-src/packages/wordcount/.npmrc b/gb-src/packages/wordcount/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/gb-src/packages/wordcount/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/gb-src/packages/wordcount/CHANGELOG.md b/gb-src/packages/wordcount/CHANGELOG.md new file mode 100644 index 0000000000000..9498e2b52738a --- /dev/null +++ b/gb-src/packages/wordcount/CHANGELOG.md @@ -0,0 +1,45 @@ +## 2.0.3 (2018-10-29) + +### Polish + +- Fix: `count` returns 0 for empty strings ([#10602](https://github.com/WordPress/gutenberg/pull/10602)) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. + +## 1.1.0 (2018-07-12) + +### New Feature + +- Updated build to work with Babel 7 ([#7832](https://github.com/WordPress/gutenberg/pull/7832)) + +### Polish + +- Moved `@WordPress/packages` repository to `@WordPress/gutenberg` ([#7805](https://github.com/WordPress/gutenberg/pull/7805)) + +## 1.0.3 (2018-05-18) + +### Polish + +- Fix: Standardized `package.json` format ([#119](https://github.com/WordPress/packages/pull/119)) + +## 1.0.2 (2018-05-08) + +### Bug Fix + +- Fix: Resolve error when input strings contains only whitespace ([#123](https://github.com/WordPress/packages/pull/123)) + +## 1.0.1 (2018-05-01) + +### Polish + +- Internal: Include `publishConfig` configuration in `package.json`. ([#114](https://github.com/WordPress/packages/pull/114)) + +## 1.0.0 (2018-04-24) + +### New Feature + +- Initial release diff --git a/gb-src/packages/wordcount/README.md b/gb-src/packages/wordcount/README.md new file mode 100644 index 0000000000000..edc6ece743369 --- /dev/null +++ b/gb-src/packages/wordcount/README.md @@ -0,0 +1,43 @@ +# Word Count + +WordPress word count utility. + +## Installation + +Install the module + +```bash +npm install @wordpress/wordcount --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## API + + + +# **count** + +Count some words. + +_Usage_ + +```js +import { count } from '@wordpress/wordcount'; +const numberOfWords = count( 'Words to count', 'words', {} ) +``` + +_Parameters_ + +- _text_ `string`: The text being processed +- _type_ `string`: The type of count. Accepts ;words', 'characters_excluding_spaces', or 'characters_including_spaces'. +- _userSettings_ `Object`: Custom settings object. + +_Returns_ + +- `number`: The word or character count. + + + + +

    Code is Poetry.

    diff --git a/gb-src/packages/wordcount/package.json b/gb-src/packages/wordcount/package.json new file mode 100644 index 0000000000000..7664cf8b99a0f --- /dev/null +++ b/gb-src/packages/wordcount/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wordpress/wordcount", + "version": "2.6.2", + "description": "WordPress word count utility.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "wordcount" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/wordcount/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/WordPress/gutenberg.git", + "directory": "packages/wordcount" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/gb-src/packages/wordcount/src/defaultSettings.js b/gb-src/packages/wordcount/src/defaultSettings.js new file mode 100644 index 0000000000000..fd19ea3debc1e --- /dev/null +++ b/gb-src/packages/wordcount/src/defaultSettings.js @@ -0,0 +1,74 @@ +export const defaultSettings = { + HTMLRegExp: /<\/?[a-z][^>]*?>/gi, + HTMLcommentRegExp: //g, + spaceRegExp: / | /gi, + HTMLEntityRegExp: /&\S+?;/g, + + // \u2014 = em-dash + connectorRegExp: /--|\u2014/g, + + // Characters to be removed from input text. + removeRegExp: new RegExp( [ + '[', + + // Basic Latin (extract) + '\u0021-\u0040\u005B-\u0060\u007B-\u007E', + + // Latin-1 Supplement (extract) + '\u0080-\u00BF\u00D7\u00F7', + + /* + * The following range consists of: + * General Punctuation + * Superscripts and Subscripts + * Currency Symbols + * Combining Diacritical Marks for Symbols + * Letterlike Symbols + * Number Forms + * Arrows + * Mathematical Operators + * Miscellaneous Technical + * Control Pictures + * Optical Character Recognition + * Enclosed Alphanumerics + * Box Drawing + * Block Elements + * Geometric Shapes + * Miscellaneous Symbols + * Dingbats + * Miscellaneous Mathematical Symbols-A + * Supplemental Arrows-A + * Braille Patterns + * Supplemental Arrows-B + * Miscellaneous Mathematical Symbols-B + * Supplemental Mathematical Operators + * Miscellaneous Symbols and Arrows + */ + '\u2000-\u2BFF', + + // Supplemental Punctuation + '\u2E00-\u2E7F', + ']', + ].join( '' ), 'g' ), + + // Remove UTF-16 surrogate points, see https://en.wikipedia.org/wiki/UTF-16#U.2BD800_to_U.2BDFFF + astralRegExp: /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, + wordsRegExp: /\S\s+/g, + characters_excluding_spacesRegExp: /\S/g, + + /* + * Match anything that is not a formatting character, excluding: + * \f = form feed + * \n = new line + * \r = carriage return + * \t = tab + * \v = vertical tab + * \u00AD = soft hyphen + * \u2028 = line separator + * \u2029 = paragraph separator + */ + characters_including_spacesRegExp: /[^\f\n\r\t\v\u00AD\u2028\u2029]/g, + l10n: { + type: 'words', + }, +}; diff --git a/gb-src/packages/wordcount/src/index.js b/gb-src/packages/wordcount/src/index.js new file mode 100644 index 0000000000000..676f7ac2564b5 --- /dev/null +++ b/gb-src/packages/wordcount/src/index.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { extend, flow } from 'lodash'; + +/** + * Internal dependencies + */ +import { defaultSettings } from './defaultSettings'; +import stripTags from './stripTags'; +import transposeAstralsToCountableChar from './transposeAstralsToCountableChar'; +import stripHTMLEntities from './stripHTMLEntities'; +import stripConnectors from './stripConnectors'; +import stripRemovables from './stripRemovables'; +import stripHTMLComments from './stripHTMLComments'; +import stripShortcodes from './stripShortcodes'; +import stripSpaces from './stripSpaces'; +import transposeHTMLEntitiesToCountableChars from './transposeHTMLEntitiesToCountableChars'; + +/** + * Private function to manage the settings. + * + * @param {string} type The type of count to be done. + * @param {Object} userSettings Custom settings for the count. + * + * @return {void|Object|*} The combined settings object to be used. + */ +function loadSettings( type, userSettings ) { + const settings = extend( defaultSettings, userSettings ); + + settings.shortcodes = settings.l10n.shortcodes || {}; + + if ( settings.shortcodes && settings.shortcodes.length ) { + settings.shortcodesRegExp = new RegExp( '\\[\\/?(?:' + settings.shortcodes.join( '|' ) + ')[^\\]]*?\\]', 'g' ); + } + + settings.type = type || settings.l10n.type; + + if ( settings.type !== 'characters_excluding_spaces' && settings.type !== 'characters_including_spaces' ) { + settings.type = 'words'; + } + + return settings; +} + +/** + * Match the regex for the type 'words' + * + * @param {string} text The text being processed + * @param {string} regex The regular expression pattern being matched + * @param {Object} settings Settings object containing regular expressions for each strip function + * + * @return {Array|{index: number, input: string}} The matched string. + */ +function matchWords( text, regex, settings ) { + text = flow( + stripTags.bind( this, settings ), + stripHTMLComments.bind( this, settings ), + stripShortcodes.bind( this, settings ), + stripSpaces.bind( this, settings ), + stripHTMLEntities.bind( this, settings ), + stripConnectors.bind( this, settings ), + stripRemovables.bind( this, settings ), + )( text ); + text = text + '\n'; + return text.match( regex ); +} + +/** + * Match the regex for either 'characters_excluding_spaces' or 'characters_including_spaces' + * + * @param {string} text The text being processed + * @param {string} regex The regular expression pattern being matched + * @param {Object} settings Settings object containing regular expressions for each strip function + * + * @return {Array|{index: number, input: string}} The matched string. + */ +function matchCharacters( text, regex, settings ) { + text = flow( + stripTags.bind( this, settings ), + stripHTMLComments.bind( this, settings ), + stripShortcodes.bind( this, settings ), + stripSpaces.bind( this, settings ), + transposeAstralsToCountableChar.bind( this, settings ), + transposeHTMLEntitiesToCountableChars.bind( this, settings ), + )( text ); + text = text + '\n'; + return text.match( regex ); +} + +/** + * Count some words. + * + * @param {string} text The text being processed + * @param {string} type The type of count. Accepts ;words', 'characters_excluding_spaces', or 'characters_including_spaces'. + * @param {Object} userSettings Custom settings object. + * + * @example + * ```js + * import { count } from '@wordpress/wordcount'; + * const numberOfWords = count( 'Words to count', 'words', {} ) + * ``` + * + * @return {number} The word or character count. + */ + +export function count( text, type, userSettings ) { + if ( '' === text ) { + return 0; + } + + if ( text ) { + const settings = loadSettings( type, userSettings ); + const matchRegExp = settings[ type + 'RegExp' ]; + const results = ( 'words' === settings.type ) ? + matchWords( text, matchRegExp, settings ) : + matchCharacters( text, matchRegExp, settings ); + + return results ? results.length : 0; + } +} diff --git a/gb-src/packages/wordcount/src/stripConnectors.js b/gb-src/packages/wordcount/src/stripConnectors.js new file mode 100644 index 0000000000000..d97f5a5b93255 --- /dev/null +++ b/gb-src/packages/wordcount/src/stripConnectors.js @@ -0,0 +1,14 @@ +/** + * Replaces items matched in the regex with spaces. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.connectorRegExp ) { + return text.replace( settings.connectorRegExp, ' ' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/stripHTMLComments.js b/gb-src/packages/wordcount/src/stripHTMLComments.js new file mode 100644 index 0000000000000..d5f80f96bad33 --- /dev/null +++ b/gb-src/packages/wordcount/src/stripHTMLComments.js @@ -0,0 +1,14 @@ +/** + * Removes items matched in the regex. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.HTMLcommentRegExp ) { + return text.replace( settings.HTMLcommentRegExp, '' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/stripHTMLEntities.js b/gb-src/packages/wordcount/src/stripHTMLEntities.js new file mode 100644 index 0000000000000..ff8682dd5304b --- /dev/null +++ b/gb-src/packages/wordcount/src/stripHTMLEntities.js @@ -0,0 +1,14 @@ +/** + * Removes items matched in the regex. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.HTMLEntityRegExp ) { + return text.replace( settings.HTMLEntityRegExp, '' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/stripRemovables.js b/gb-src/packages/wordcount/src/stripRemovables.js new file mode 100644 index 0000000000000..f781ffdc7a144 --- /dev/null +++ b/gb-src/packages/wordcount/src/stripRemovables.js @@ -0,0 +1,14 @@ +/** + * Removes items matched in the regex. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.removeRegExp ) { + return text.replace( settings.removeRegExp, '' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/stripShortcodes.js b/gb-src/packages/wordcount/src/stripShortcodes.js new file mode 100644 index 0000000000000..4be60f463ec9c --- /dev/null +++ b/gb-src/packages/wordcount/src/stripShortcodes.js @@ -0,0 +1,14 @@ +/** + * Replaces items matched in the regex with a new line. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.shortcodesRegExp ) { + return text.replace( settings.shortcodesRegExp, '\n' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/stripSpaces.js b/gb-src/packages/wordcount/src/stripSpaces.js new file mode 100644 index 0000000000000..867811945addd --- /dev/null +++ b/gb-src/packages/wordcount/src/stripSpaces.js @@ -0,0 +1,13 @@ +/** + * Replaces items matched in the regex with spaces. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.spaceRegExp ) { + return text.replace( settings.spaceRegExp, ' ' ); + } +} diff --git a/gb-src/packages/wordcount/src/stripTags.js b/gb-src/packages/wordcount/src/stripTags.js new file mode 100644 index 0000000000000..1d7aefbbdf29a --- /dev/null +++ b/gb-src/packages/wordcount/src/stripTags.js @@ -0,0 +1,13 @@ +/** + * Replaces items matched in the regex with new line + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.HTMLRegExp ) { + return text.replace( settings.HTMLRegExp, '\n' ); + } +} diff --git a/gb-src/packages/wordcount/src/test/index.test.js b/gb-src/packages/wordcount/src/test/index.test.js new file mode 100644 index 0000000000000..2537ca49e213a --- /dev/null +++ b/gb-src/packages/wordcount/src/test/index.test.js @@ -0,0 +1,114 @@ +/** + * Internal dependencies + */ + +import { count } from '../'; + +const mockData = { + l10n: { + shortcodes: [ + 'shortcode', + ], + }, +}; + +describe( 'WordCounter', () => { + const dataProvider = [ + { + message: 'Basic test.', + string: 'one two three', + words: 3, + characters_excluding_spaces: 11, + characters_including_spaces: 13, + }, + { + message: 'HTML tags.', + string: 'one two
    three', + words: 3, + characters_excluding_spaces: 11, + characters_including_spaces: 12, + }, + { + message: 'Line breaks.', + string: 'one\ntwo\nthree', + words: 3, + characters_excluding_spaces: 11, + characters_including_spaces: 11, + }, + { + message: 'Encoded spaces.', + string: 'one two three', + words: 3, + characters_excluding_spaces: 11, + characters_including_spaces: 13, + }, + { + message: 'Punctuation.', + string: 'It’s two three \u2026 4?', + words: 3, + characters_excluding_spaces: 15, + characters_including_spaces: 19, + }, + { + message: 'Em dash.', + string: 'one\u2014two--three', + words: 3, + characters_excluding_spaces: 14, + characters_including_spaces: 14, + }, + { + message: 'Shortcodes.', + string: 'one [shortcode attribute="value"]two[/shortcode]three', + words: 3, + characters_excluding_spaces: 11, + characters_including_spaces: 12, + }, + { + message: 'Astrals.', + string: '\uD83D\uDCA9', + words: 1, + characters_excluding_spaces: 1, + characters_including_spaces: 1, + }, + { + message: 'HTML comment.', + string: 'onetwo three', + words: 2, + characters_excluding_spaces: 11, + characters_including_spaces: 12, + }, + { + message: 'HTML entity.', + string: '> test', + words: 1, + characters_excluding_spaces: 5, + characters_including_spaces: 6, + }, + { + message: 'Empty tags', + string: '

    ', + words: 0, + characters_excluding_spaces: 0, + characters_including_spaces: 0, + }, + { + message: 'Empty string', + string: '', + words: 0, + characters_excluding_spaces: 0, + characters_including_spaces: 0, + }, + ]; + + const types = [ 'words', 'characters_excluding_spaces', 'characters_including_spaces' ]; + + dataProvider.forEach( ( item ) => { + types.forEach( ( type ) => { + const result = count( item.string, type, mockData ); + test( item.message + ' (' + type + ')', () => { + expect( result ).toBe( item[ type ] ); + } ); + } ); + } ); +} ); + diff --git a/gb-src/packages/wordcount/src/transposeAstralsToCountableChar.js b/gb-src/packages/wordcount/src/transposeAstralsToCountableChar.js new file mode 100644 index 0000000000000..84c7f63bb13b4 --- /dev/null +++ b/gb-src/packages/wordcount/src/transposeAstralsToCountableChar.js @@ -0,0 +1,14 @@ +/** + * Replaces items matched in the regex with character. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.astralRegExp ) { + return text.replace( settings.astralRegExp, 'a' ); + } + return text; +} diff --git a/gb-src/packages/wordcount/src/transposeHTMLEntitiesToCountableChars.js b/gb-src/packages/wordcount/src/transposeHTMLEntitiesToCountableChars.js new file mode 100644 index 0000000000000..17a2f462e75a9 --- /dev/null +++ b/gb-src/packages/wordcount/src/transposeHTMLEntitiesToCountableChars.js @@ -0,0 +1,14 @@ +/** + * Replaces items matched in the regex with a single character. + * + * @param {Object} settings The main settings object containing regular expressions + * @param {string} text The string being counted. + * + * @return {string} The manipulated text. + */ +export default function( settings, text ) { + if ( settings.HTMLEntityRegExp ) { + return text.replace( settings.HTMLEntityRegExp, 'a' ); + } + return text; +} diff --git a/gb-src/phpcs.xml.dist b/gb-src/phpcs.xml.dist new file mode 100644 index 0000000000000..381e9f22e3e2a --- /dev/null +++ b/gb-src/phpcs.xml.dist @@ -0,0 +1,83 @@ + + + Sniffs for WordPress plugins, with minor modifications for Gutenberg + + + + + + + + + + + + + + + + + + + packages/block-library/src/* + + + + + + ./bin + ./gutenberg.php + ./lib + ./packages + ./phpunit + ./post-content.php + + + ./packages/block-serialization-spec-parser/parser.php + + + + gutenberg.php + + + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + phpunit/* + + + + + ./packages/block-serialization-default-parser/parser.php + + + ./packages/block-serialization-default-parser/parser.php + + + ./packages/block-serialization-default-parser/parser.php + + + + ./packages/block-serialization-default-parser/parser.php + + diff --git a/gb-src/phpunit.xml.dist b/gb-src/phpunit.xml.dist new file mode 100644 index 0000000000000..e799a44754caf --- /dev/null +++ b/gb-src/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + ./phpunit/ + + + diff --git a/gb-src/phpunit/bootstrap.php b/gb-src/phpunit/bootstrap.php new file mode 100644 index 0000000000000..17989ee336b11 --- /dev/null +++ b/gb-src/phpunit/bootstrap.php @@ -0,0 +1,45 @@ +post->create_and_get(); + } + + /** + * Tests paths added if missing. + */ + function test_localizes_script() { + $preload_paths = gutenberg_extend_block_editor_preload_paths( array(), self::$post ); + + $expected_blocks_path = array( '/wp/v2/blocks', 'OPTIONS' ); + $expected_autosaves_path = sprintf( '/wp/v2/%s/%d/autosaves?context=edit', 'posts', self::$post->ID ); + + $this->assertEquals( array( $expected_autosaves_path, $expected_blocks_path ), $preload_paths ); + } + + /** + * Tests paths not added if present. + */ + function test_replaces_registered_properties() { + $existing_blocks_path = array( '/wp/v2/blocks', 'OPTIONS' ); + $existing_autosaves_path = sprintf( '/wp/v2/%s/%d/autosaves?context=edit', 'posts', self::$post->ID ); + $existing_preload_paths = array( $existing_blocks_path, $existing_autosaves_path ); + + $preload_paths = gutenberg_extend_block_editor_preload_paths( $existing_preload_paths, self::$post ); + + $this->assertEquals( $existing_preload_paths, $preload_paths ); + } +} diff --git a/gb-src/phpunit/class-extend-styles-test.php b/gb-src/phpunit/class-extend-styles-test.php new file mode 100644 index 0000000000000..6ae12ada22797 --- /dev/null +++ b/gb-src/phpunit/class-extend-styles-test.php @@ -0,0 +1,185 @@ +restore_editor_styles(); + } + + /** + * Restores the existence of `editor-styles.css` to its original state. + */ + protected function restore_editor_styles() { + $path = gutenberg_dir_path() . 'build/editor/editor-styles.css'; + + if ( $this->original_file ) { + if ( $this->original_file !== $path ) { + rename( $this->original_file, $path ); + } + } elseif ( file_exists( $path ) ) { + unlink( $path ); + } + + $this->style_contents = null; + $this->original_file = null; + } + + /** + * Guarantees that an `editor-styles.css` file exists, if and only if it + * should exist. Assigns `style_contents` according to the contents of the + * file if it should exist. Renames the existing file temporarily if it + * exists but should not. + * + * @param bool $should_exist Whether the editor styles file should exist. + */ + protected function ensure_editor_styles( $should_exist = true ) { + $path = gutenberg_dir_path() . 'build/editor/editor-styles.css'; + + if ( file_exists( $path ) ) { + if ( $should_exist ) { + $this->style_contents = file_get_contents( $path ); + $this->original_file = $path; + } else { + rename( $path, $path . '.bak' ); + $this->original_file = $path . '.bak'; + } + } elseif ( $should_exist ) { + $this->style_contents = ''; + file_put_contents( $path, $this->style_contents ); + $this->original_file = null; + } + } + + /** + * Tests a non-existent build `styles`. + */ + function test_without_built_styles() { + $this->ensure_editor_styles( false ); + + $settings = array( + 'styles' => array( + array( 'css' => 'original' ), + array( 'css' => 'someother' ), + ), + ); + + $result = gutenberg_extend_block_editor_styles( $settings ); + + $this->assertEquals( $settings, $result ); + } + + /** + * Tests an unset `styles` setting. + */ + function test_unset_editor_settings_style() { + $this->ensure_editor_styles(); + + $settings = array(); + + $settings = gutenberg_extend_block_editor_styles( $settings ); + + $this->assertEquals( + array( array( 'css' => $this->style_contents ) ), + $settings['styles'] + ); + } + + /** + * Tests replacing the default styles. + */ + function test_replace_default_editor_styles() { + $this->ensure_editor_styles(); + $default_styles = file_get_contents( + ABSPATH . WPINC . '/css/dist/editor/editor-styles.css' + ); + + $settings = array( + 'styles' => array( + array( 'css' => $default_styles ), + array( 'css' => 'someother' ), + ), + ); + + $settings = gutenberg_extend_block_editor_styles( $settings ); + + $this->assertEquals( + array( + array( 'css' => $this->style_contents ), + array( 'css' => 'someother' ), + ), + $settings['styles'] + ); + } + + /** + * Tests replacing the rearranged default styles. + */ + function test_replace_rearranged_default_editor_styles() { + $this->ensure_editor_styles(); + $default_styles = file_get_contents( + ABSPATH . WPINC . '/css/dist/editor/editor-styles.css' + ); + + $settings = array( + 'styles' => array( + array( 'css' => 'someother' ), + array( 'css' => $default_styles ), + ), + ); + + $settings = gutenberg_extend_block_editor_styles( $settings ); + + $this->assertEquals( + array( + array( 'css' => 'someother' ), + array( 'css' => $this->style_contents ), + ), + $settings['styles'] + ); + } + + /** + * Tests when the default styles aren't in the styles setting. + */ + function test_without_default_editor_styles() { + $this->ensure_editor_styles(); + + $settings = array( + 'styles' => array( + array( 'css' => 'someother' ), + ), + ); + + $settings = gutenberg_extend_block_editor_styles( $settings ); + + $this->assertEquals( + array( + array( 'css' => $this->style_contents ), + array( 'css' => 'someother' ), + ), + $settings['styles'] + ); + } + +} diff --git a/gb-src/phpunit/class-override-script-test.php b/gb-src/phpunit/class-override-script-test.php new file mode 100644 index 0000000000000..034fa2f25a618 --- /dev/null +++ b/gb-src/phpunit/class-override-script-test.php @@ -0,0 +1,83 @@ +query( 'gutenberg-dummy-script', 'registered' ); + $this->assertEquals( array( 'dependency', 'wp-i18n' ), $script->deps ); + } + + /** + * Tests that script properties are overridden. + */ + function test_replaces_registered_properties() { + gutenberg_override_script( + 'gutenberg-dummy-script', + 'https://example.com/updated', + array( 'updated-dependency' ), + 'updated-version', + true + ); + + global $wp_scripts; + $script = $wp_scripts->query( 'gutenberg-dummy-script', 'registered' ); + $this->assertEquals( 'https://example.com/updated', $script->src ); + $this->assertEquals( array( 'updated-dependency', 'wp-i18n' ), $script->deps ); + $this->assertEquals( 'updated-version', $script->ver ); + $this->assertEquals( 1, $script->extra['group'] ); + } + + /** + * Tests that new script registers normally if no handle by the name. + */ + function test_registers_new_script() { + gutenberg_override_script( + 'gutenberg-second-dummy-script', + 'https://example.com/', + array( 'dependency' ), + 'version', + true + ); + + global $wp_scripts; + $script = $wp_scripts->query( 'gutenberg-second-dummy-script', 'registered' ); + $this->assertEquals( 'https://example.com/', $script->src ); + $this->assertEquals( array( 'dependency', 'wp-i18n' ), $script->deps ); + $this->assertEquals( 'version', $script->ver ); + $this->assertEquals( 1, $script->extra['group'] ); + } +} diff --git a/gb-src/phpunit/class-vendor-script-filename-test.php b/gb-src/phpunit/class-vendor-script-filename-test.php new file mode 100644 index 0000000000000..b472377ec2fbf --- /dev/null +++ b/gb-src/phpunit/class-vendor-script-filename-test.php @@ -0,0 +1,65 @@ +assertEquals( + $expected_filename_pattern, + $actual_filename_pattern, + 'Cacheable filename for ' . $url + ); + } +} diff --git a/gb-src/playground/.babelrc b/gb-src/playground/.babelrc new file mode 100644 index 0000000000000..5b8ba9724e13a --- /dev/null +++ b/gb-src/playground/.babelrc @@ -0,0 +1,9 @@ +{ + "presets": ["@wordpress/babel-preset-default"], + "plugins": [ + [ "@babel/plugin-transform-react-jsx", { + "pragma": "createElement" + } ], + "babel-plugin-inline-json-import" + ] +} diff --git a/gb-src/playground/src/editor-styles.scss b/gb-src/playground/src/editor-styles.scss new file mode 100644 index 0000000000000..7337f68fe2bc7 --- /dev/null +++ b/gb-src/playground/src/editor-styles.scss @@ -0,0 +1,51 @@ +.editor-styles-wrapper { + font-family: $editor-font; + font-size: $editor-font-size; + line-height: $editor-line-height; + color: $dark-gray-900; + + p { + font-size: inherit; + line-height: inherit; + } + + ul, + ol { + margin: 0; + padding: 0; + } + + ul li, + ol li { + // This overrides a bottom margin globally applied to list items in wp-admin. + margin-bottom: initial; + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + ul ul, + ol ul { + list-style-type: circle; + } + + // These are default editor styles + .wp-block { + max-width: 700px; + + // TODO: the default padding/margin of the block editor content are not set properly + margin-left: auto; + margin-right: auto; + } + .wp-block[data-align="wide"] { + max-width: 1100px; + } + .wp-block[data-align="full"] { + max-width: none; + } +} diff --git a/gb-src/playground/src/index.html b/gb-src/playground/src/index.html new file mode 100644 index 0000000000000..11576c6aa4118 --- /dev/null +++ b/gb-src/playground/src/index.html @@ -0,0 +1,13 @@ + + + + + The Gutenberg Playground + + + + +
    + + + diff --git a/gb-src/playground/src/index.js b/gb-src/playground/src/index.js new file mode 100644 index 0000000000000..b74e30091c173 --- /dev/null +++ b/gb-src/playground/src/index.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import '@wordpress/editor'; // This shouldn't be necessary + +import { render, useState, Fragment } from '@wordpress/element'; +import { + BlockEditorKeyboardShortcuts, + BlockEditorProvider, + BlockList, + WritingFlow, + ObserveTyping, +} from '@wordpress/block-editor'; +import { + Popover, + SlotFillProvider, + DropZoneProvider, +} from '@wordpress/components'; +import { registerCoreBlocks } from '@wordpress/block-library'; +import '@wordpress/format-library'; + +/** + * Internal dependencies + */ +import './style.scss'; + +/* eslint-disable no-restricted-syntax */ +import '@wordpress/components/build-style/style.css'; +import '@wordpress/block-editor/build-style/style.css'; +import '@wordpress/block-library/build-style/style.css'; +import '@wordpress/block-library/build-style/editor.css'; +import '@wordpress/block-library/build-style/theme.css'; +import '@wordpress/format-library/build-style/style.css'; +/* eslint-enable no-restricted-syntax */ + +function App() { + const [ blocks, updateBlocks ] = useState( [] ); + + return ( + +
    +

    Gutenberg Playground

    +
    +
    + + + +
    + + + + + + +
    + +
    +
    +
    +
    +
    + ); +} + +registerCoreBlocks(); +render( + , + document.querySelector( '#app' ) +); diff --git a/gb-src/playground/src/reset.scss b/gb-src/playground/src/reset.scss new file mode 100644 index 0000000000000..e8fca9903cccd --- /dev/null +++ b/gb-src/playground/src/reset.scss @@ -0,0 +1,32 @@ +// TODO: screen reader text className should be replaced +// with a component in the a11y package. +.screen-reader-text { + border: 0; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + word-wrap: normal !important; +} + +:root { + @include reset; +} + +html, +body { + margin: 0; + padding: 0; + font-family: $default-font; + font-size: $default-font-size; +} + +a, +div { + outline: 0; +} diff --git a/gb-src/playground/src/style.scss b/gb-src/playground/src/style.scss new file mode 100644 index 0000000000000..63a330e00a2d6 --- /dev/null +++ b/gb-src/playground/src/style.scss @@ -0,0 +1,53 @@ +@import "../../assets/stylesheets/colors"; +@import "../../assets/stylesheets/variables"; +@import "../../assets/stylesheets/mixins"; +@import "../../assets/stylesheets/breakpoints"; +@import "../../assets/stylesheets/animations"; +@import "../../assets/stylesheets/z-index"; + +@import "./reset"; +@import "./editor-styles"; + + +.playground__header { + padding: 20px; + border-bottom: 1px solid #ddd; +} + +.playground__logo { + font-size: 20px; + font-weight: 300; +} + +.playground__body { + padding-top: 20px; + + img { + max-width: 100%; + height: auto; + } + + iframe { + width: 100%; + } + + .components-navigate-regions { + height: 100%; + } +} + +/** + * Animations + */ + +// These keyframes should not be part of the _animations.scss mixins file. +// Because keyframe animations can't be defined as mixins properly, they are duplicated. +// Since hey are intended only for the editor, we add them here instead. +@keyframes edit-post__fade-in-animation { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/gb-src/post-content.php b/gb-src/post-content.php new file mode 100644 index 0000000000000..f286c181f5dbb --- /dev/null +++ b/gb-src/post-content.php @@ -0,0 +1,190 @@ + + +
    +

    +
    + + + +

    pieces of content—somewhat similar to LEGO bricks—that you can move around and interact with. Move your cursor around and you’ll notice the different blocks light up with outlines and arrows. Press the arrows to reposition blocks quickly, without fearing about losing things in the process of copying and pasting.', 'gutenberg' ); ?>

    + + + +

    text block the most basic block of all. The text block has its own controls to be moved freely around the post...', 'gutenberg' ); ?>

    + + + +

    + + + +

    + + + +

    + + + +

    + + + +
    +
    <?php esc_attr_e( 'Beautiful landscape', 'gutenberg' ); ?> +
    +
    +
    + + + +

    + + + +

    Inserter Tool', 'gutenberg' ); ?>

    + + + +

    (+) button you’ll see around the editor—which allows you to browse all available content blocks and add them into your post. Plugins and themes are able to register their own, opening up all sort of possibilities for rich editing and publishing.', 'gutenberg' ); ?>

    + + + +

    + + + +
      +
    • +
    • +
    • +
    • +
    • +
    • Lists like this one of course :)', 'gutenberg' ); ?>
    • +
    + + + +
    + + + +

    + + + +

    + + + +
    +

    + +
    + + + +

    + + + +

    + + + + + + + +

    + + + +

    + + + +

    wide and full-wide alignments with galleries, you can create a very media rich layout, very quickly:', 'gutenberg' ); ?>

    + + + +
    <?php _e( 'Accessibility is important — don’t forget image alt attribute', 'gutenberg' ); ?>
    + + + +

    + + + + + + + +

    + + + +

    + + + +
    +https://vimeo.com/22439234 +
    + + + +

    + + + +

    + + + +

    + + GitHub repository.', 'gutenberg' ), + 'https://github.com/WordPress/gutenberg' + ); + ?> + +

    + + + +
    + + + +
    + + + +

    + + + +

    👋

    + diff --git a/gb-src/test/integration/__snapshots__/blocks-raw-handling.test.js.snap b/gb-src/test/integration/__snapshots__/blocks-raw-handling.test.js.snap new file mode 100644 index 0000000000000..29b4c95f5806b --- /dev/null +++ b/gb-src/test/integration/__snapshots__/blocks-raw-handling.test.js.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rawHandler should convert HTML post to blocks with minimal content changes 1`] = ` +" +

    Howdy

    + + + +
    \\"\\"/
    + + + +

    This is a paragraph.

    + + + +

    Preserve me!

    + + + +

    More tag

    + + + + + + + +

    Shortcode

    + + + + + + + +
    +
    Term
    +
    + Description. +
    +
    + + + +
    1. Item
    + + + +

    Text.

    + + + +

    Heading

    Text.

    +" +`; + +exports[`rawHandler should convert a caption shortcode 1`] = ` +" +
    \\"\\"
    test
    +" +`; + +exports[`rawHandler should convert a caption shortcode with caption 1`] = ` +" +
    \\"\\"
    test
    +" +`; + +exports[`rawHandler should convert a caption shortcode with link 1`] = ` +" +
    \\"Bell
    Bell on wharf in San Francisco
    +" +`; diff --git a/gb-src/test/integration/blocks-raw-handling.test.js b/gb-src/test/integration/blocks-raw-handling.test.js new file mode 100644 index 0000000000000..fda576a042cbb --- /dev/null +++ b/gb-src/test/integration/blocks-raw-handling.test.js @@ -0,0 +1,290 @@ +/** + * External dependencies + */ +import fs from 'fs'; +import path from 'path'; + +/** + * WordPress dependencies + */ +import { + getBlockContent, + pasteHandler, + rawHandler, + serialize, +} from '@wordpress/blocks'; +import { registerCoreBlocks } from '@wordpress/block-library'; + +function readFile( filePath ) { + return fs.existsSync( filePath ) ? fs.readFileSync( filePath, 'utf8' ).trim() : ''; +} + +describe( 'Blocks raw handling', () => { + beforeAll( () => { + // Load all hooks that modify blocks + require( '../../packages/editor/src/hooks' ); + registerCoreBlocks(); + } ); + + it( 'should filter inline content', () => { + const filtered = pasteHandler( { + HTML: '

    test

    ', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'test' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should ignore Google Docs UID tag', () => { + const filtered = pasteHandler( { + HTML: 'test', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    test

    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should ignore Google Docs UID tag in inline mode', () => { + const filtered = pasteHandler( { + HTML: 'test', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'test' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse Markdown', () => { + const filtered = pasteHandler( { + HTML: '* one
    * two
    * three', + plainText: '* one\n* two\n* three', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '
    • one
    • two
    • three
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse inline Markdown', () => { + const filtered = pasteHandler( { + HTML: 'Some **bold** text.', + plainText: 'Some **bold** text.', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some bold text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse HTML in plainText', () => { + const filtered = pasteHandler( { + HTML: '<p>Some <strong>bold</strong> text.</p>', + plainText: '

    Some bold text.

    ', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some bold text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse Markdown with HTML', () => { + const filtered = pasteHandler( { + HTML: '', + plainText: '# Some heading\n\nA paragraph.', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    Some heading

    A paragraph.

    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should break up forced inline content', () => { + const filtered = pasteHandler( { + HTML: '

    test

    test

    ', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'test
    test' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should normalize decomposed characters', () => { + const filtered = pasteHandler( { + HTML: 'schön', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'schön' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should treat single list item as inline text', () => { + const filtered = pasteHandler( { + HTML: '
    • Some bold text.
    ', + plainText: 'Some bold text.\n', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some bold text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should treat multiple list items as a block', () => { + const filtered = pasteHandler( { + HTML: '
    • One
    • Two
    • Three
    ', + plainText: 'One\nTwo\nThree\n', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '
    • One
    • Two
    • Three
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should correctly handle quotes with one paragraphs and no citation', () => { + const filtered = pasteHandler( { + HTML: '

    chicken

    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    chicken

    ' ); + expect( console ).toHaveLogged(); + } ); + it( 'should correctly handle quotes with multiple paragraphs and no citation', () => { + const filtered = pasteHandler( { + HTML: '

    chicken

    ribs

    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    chicken

    ribs

    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should correctly handle quotes with paragraph and citation at the end', () => { + const filtered = pasteHandler( { + HTML: '

    chicken

    ribs
    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    chicken

    ribs
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should handle a citation before the content', () => { + const filtered = pasteHandler( { + HTML: '
    ribs

    ribs

    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    ribs

    ribs
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should handle a citation in the middle of the content', () => { + const filtered = pasteHandler( { + HTML: '

    chicken

    ribs

    ribs

    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    chicken

    ribs

    ribs
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should correctly handle quotes with only a citation', () => { + const filtered = pasteHandler( { + HTML: '
    ribs
    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    ribs
    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should convert to paragraph quotes with more than one cite', () => { + const filtered = pasteHandler( { + HTML: '
    ribsribs
    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    ribsribs

    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should convert to paragraph quotes with more than one cite and at least one paragraph', () => { + const filtered = pasteHandler( { + HTML: '

    chicken

    ribsribs
    ', + mode: 'AUTO', + } ).map( getBlockContent ).join( '' ); + + expect( filtered ).toBe( '

    chickenribsribs

    ' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should paste gutenberg content from plain text', () => { + const block = ''; + expect( serialize( pasteHandler( { + plainText: block, + mode: 'AUTO', + } ) ) ).toBe( block ); + } ); + + describe( 'pasteHandler', () => { + [ + 'plain', + 'classic', + 'apple', + 'google-docs', + 'google-docs-table', + 'google-docs-table-with-comments', + 'google-docs-with-comments', + 'ms-word', + 'ms-word-styled', + 'ms-word-online', + 'evernote', + 'iframe-embed', + 'one-image', + 'two-images', + 'markdown', + 'wordpress', + 'gutenberg', + 'caption-shortcode', + ].forEach( ( type ) => { + it( type, () => { + const HTML = readFile( path.join( __dirname, `fixtures/${ type }-in.html` ) ); + const plainText = readFile( path.join( __dirname, `fixtures/${ type }-in.txt` ) ); + const output = readFile( path.join( __dirname, `fixtures/${ type }-out.html` ) ); + const converted = pasteHandler( { HTML, plainText, canUserUseUnfilteredHTML: true } ); + const serialized = typeof converted === 'string' ? converted : serialize( converted ); + + expect( serialized ).toBe( output ); + + if ( type !== 'gutenberg' ) { + expect( console ).toHaveLogged(); + } + } ); + } ); + } ); +} ); + +describe( 'rawHandler', () => { + it( 'should convert HTML post to blocks with minimal content changes', () => { + const HTML = readFile( path.join( __dirname, 'fixtures/wordpress-convert.html' ) ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it( 'should convert a caption shortcode', () => { + const HTML = readFile( path.join( __dirname, 'fixtures/shortcode-caption.html' ) ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it( 'should convert a caption shortcode with link', () => { + const HTML = readFile( path.join( __dirname, 'fixtures/shortcode-caption-with-link.html' ) ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it( 'should convert a caption shortcode with caption', () => { + const HTML = readFile( path.join( __dirname, 'fixtures/shortcode-caption-with-caption-link.html' ) ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); +} ); diff --git a/gb-src/test/integration/fixtures/apple-in.html b/gb-src/test/integration/fixtures/apple-in.html new file mode 100644 index 0000000000000..fb2ae65e3ae9d --- /dev/null +++ b/gb-src/test/integration/fixtures/apple-in.html @@ -0,0 +1,61 @@ +

    This is a title

    +


    +

    This is a heading

    +


    +

    This is a paragraph with a link.

    +


    +
      +
    • A
    • +
    • Bulleted
    • +
        +
      • Indented
      • +
      +
    • List
    • +
    +


    +
      +
    1. One
    2. +
    3. Two
    4. +
    5. Three
    6. +
    +


    + + + + + + + + + + + + + + + + + + +
    +

    One

    +
    +

    Two

    +
    +

    Three

    +
    +

    1

    +
    +

    2

    +
    +

    3

    +
    +

    I

    +
    +

    II

    +
    +

    III

    +
    +


    +

    An image:

    +


    diff --git a/gb-src/test/integration/fixtures/apple-out.html b/gb-src/test/integration/fixtures/apple-out.html new file mode 100644 index 0000000000000..40d4f1a8d133e --- /dev/null +++ b/gb-src/test/integration/fixtures/apple-out.html @@ -0,0 +1,45 @@ + +

    This is a title

    + + + +

    This is a heading

    + + + +

    This is a paragraph with a link.

    + + + +
    • A
    • Bulleted
      • Indented
    • List
    + + + +
    1. One
    2. Two
    3. Three
    + + + +
    +One + +Two + +Three +
    +1 + +2 + +3 +
    +I + +II + +III +
    + + + +

    An image:

    + diff --git a/gb-src/test/integration/fixtures/classic-in.html b/gb-src/test/integration/fixtures/classic-in.html new file mode 100644 index 0000000000000..82d9d73687ed8 --- /dev/null +++ b/gb-src/test/integration/fixtures/classic-in.html @@ -0,0 +1,8 @@ +

    First paragraph

    +

    +

    Second paragraph

    +

    Third paragraph

    + +

    Fourth paragraph

    +

    Fifth paragraph

    +

    Sixth paragraph

    diff --git a/gb-src/test/integration/fixtures/classic-out.html b/gb-src/test/integration/fixtures/classic-out.html new file mode 100644 index 0000000000000..dbc94528db7b2 --- /dev/null +++ b/gb-src/test/integration/fixtures/classic-out.html @@ -0,0 +1,31 @@ + +

    First paragraph

    + + + + + + + +

    Second paragraph

    + + + +

    Third paragraph

    + + + +

    Fourth paragraph

    + + + + + + + +

    Fifth paragraph

    + + + +

    Sixth paragraph

    + diff --git a/gb-src/test/integration/fixtures/evernote-in.html b/gb-src/test/integration/fixtures/evernote-in.html new file mode 100644 index 0000000000000..5ab9263083d13 --- /dev/null +++ b/gb-src/test/integration/fixtures/evernote-in.html @@ -0,0 +1,12 @@ +
    This is a paragraph. +

    This is a link. +

    • An
    • Unordered
      • Indented
    • List

    1. One
    2. Two
      1. Indented
    3. Three


    + + +
    One +
    Two +
    Three +
    Four +
    Five +
    Six +
    \ No newline at end of file diff --git a/gb-src/test/integration/fixtures/evernote-out.html b/gb-src/test/integration/fixtures/evernote-out.html new file mode 100644 index 0000000000000..c984df312f0be --- /dev/null +++ b/gb-src/test/integration/fixtures/evernote-out.html @@ -0,0 +1,31 @@ + +

    This is a paragraph. +
    This is a link. +

    + + + +
    • An
    • Unordered
      • Indented
    • List
    + + + +
    1. One
    2. Two
      1. Indented
    3. Three
    + + + +
    + + + +
    One +Two +Three +
    Four +Five +Six +
    + + + +
    + diff --git a/gb-src/test/integration/fixtures/google-docs-in.html b/gb-src/test/integration/fixtures/google-docs-in.html new file mode 100644 index 0000000000000..e70f79825bf30 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-in.html @@ -0,0 +1 @@ +

    This is a title


    This is a heading


    Formatting test: bold, italic, link, strikethrough, superscript, subscript, nested.


    • A

    • Bulleted

      • Indented

    • List


    1. One

    2. Two

    3. Three


    One

    Two

    Three

    1

    2

    3

    I

    II

    III




    An image:



    \ No newline at end of file diff --git a/gb-src/test/integration/fixtures/google-docs-out.html b/gb-src/test/integration/fixtures/google-docs-out.html new file mode 100644 index 0000000000000..576b2cd7fda73 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-out.html @@ -0,0 +1,35 @@ + +

    This is a title

    + + + +

    This is a heading

    + + + +

    Formatting test: bold, italic, link, strikethrough, superscript, subscript, nested.

    + + + +
    • A
    • Bulleted
      • Indented
    • List
    + + + +
    1. One
    2. Two
    3. Three
    + + + +
    OneTwoThree
    123
    IIIIII
    + + + +
    + + + +

    An image:

    + + + +
    + diff --git a/gb-src/test/integration/fixtures/google-docs-table-in.html b/gb-src/test/integration/fixtures/google-docs-table-in.html new file mode 100644 index 0000000000000..ad03a2dff0517 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-table-in.html @@ -0,0 +1 @@ +

    One

    Two

    Three

    1

    2

    3

    I

    II

    III

    \ No newline at end of file diff --git a/gb-src/test/integration/fixtures/google-docs-table-out.html b/gb-src/test/integration/fixtures/google-docs-table-out.html new file mode 100644 index 0000000000000..fbca760d25258 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-table-out.html @@ -0,0 +1,3 @@ + +
    OneTwoThree
    123
    IIIIII
    + diff --git a/gb-src/test/integration/fixtures/google-docs-table-with-comments-in.html b/gb-src/test/integration/fixtures/google-docs-table-with-comments-in.html new file mode 100644 index 0000000000000..a23b54c17a5e3 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-table-with-comments-in.html @@ -0,0 +1,4 @@ + +

    One

    Two

    Three

    1

    2

    3

    I

    II

    III

    + + \ No newline at end of file diff --git a/gb-src/test/integration/fixtures/google-docs-table-with-comments-out.html b/gb-src/test/integration/fixtures/google-docs-table-with-comments-out.html new file mode 100644 index 0000000000000..fbca760d25258 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-table-with-comments-out.html @@ -0,0 +1,3 @@ + +
    OneTwoThree
    123
    IIIIII
    + diff --git a/gb-src/test/integration/fixtures/google-docs-with-comments-in.html b/gb-src/test/integration/fixtures/google-docs-with-comments-in.html new file mode 100644 index 0000000000000..e838e0198fe8f --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-with-comments-in.html @@ -0,0 +1,4 @@ + +

    This is a title


    This is a heading


    Formatting test: bold, italic, link, strikethrough, superscript, subscript, nested.


    • A

    • Bulleted

      • Indented

    • List


    1. One

    2. Two

    3. Three


    One

    Two

    Three

    1

    2

    3

    I

    II

    III




    An image:



    + + \ No newline at end of file diff --git a/gb-src/test/integration/fixtures/google-docs-with-comments-out.html b/gb-src/test/integration/fixtures/google-docs-with-comments-out.html new file mode 100644 index 0000000000000..576b2cd7fda73 --- /dev/null +++ b/gb-src/test/integration/fixtures/google-docs-with-comments-out.html @@ -0,0 +1,35 @@ + +

    This is a title

    + + + +

    This is a heading

    + + + +

    Formatting test: bold, italic, link, strikethrough, superscript, subscript, nested.

    + + + +
    • A
    • Bulleted
      • Indented
    • List
    + + + +
    1. One
    2. Two
    3. Three
    + + + +
    OneTwoThree
    123
    IIIIII
    + + + +
    + + + +

    An image:

    + + + +
    + diff --git a/gb-src/test/integration/fixtures/gutenberg-in.html b/gb-src/test/integration/fixtures/gutenberg-in.html new file mode 100644 index 0000000000000..de01a2a22606a --- /dev/null +++ b/gb-src/test/integration/fixtures/gutenberg-in.html @@ -0,0 +1,3 @@ + +

    Test

    + diff --git a/gb-src/test/integration/fixtures/gutenberg-out.html b/gb-src/test/integration/fixtures/gutenberg-out.html new file mode 100644 index 0000000000000..83d523bbd7303 --- /dev/null +++ b/gb-src/test/integration/fixtures/gutenberg-out.html @@ -0,0 +1,3 @@ + +

    Test

    + diff --git a/gb-src/test/integration/fixtures/iframe-embed-in.html b/gb-src/test/integration/fixtures/iframe-embed-in.html new file mode 100644 index 0000000000000..862a9705ec96e --- /dev/null +++ b/gb-src/test/integration/fixtures/iframe-embed-in.html @@ -0,0 +1 @@ + diff --git a/gb-src/test/integration/fixtures/iframe-embed-out.html b/gb-src/test/integration/fixtures/iframe-embed-out.html new file mode 100644 index 0000000000000..6eeb487da82b5 --- /dev/null +++ b/gb-src/test/integration/fixtures/iframe-embed-out.html @@ -0,0 +1,3 @@ + +
    + diff --git a/gb-src/test/integration/fixtures/markdown-in.txt b/gb-src/test/integration/fixtures/markdown-in.txt new file mode 100644 index 0000000000000..b636a78143800 --- /dev/null +++ b/gb-src/test/integration/fixtures/markdown-in.txt @@ -0,0 +1,42 @@ +# This is a heading with *italic* + +This is a paragraph with a [link](https://w.org/), **bold**, and ~~strikethrough~~. + +Preserve +line breaks please. + +## Lists + +* A +* Bulleted + * Indented +* List + +1. One +2. Two +3. Three + +## Table + +First Header | Second Header +------------ | ------------- +Content from cell 1 | Content from cell 2 +Content in the first column | Content in the second column + +| | | +|---|---| +| | Table with empty cells. | + +## Quote + +> First +> +> Second + +## Code + +Inline `code` tags should work. + +```html +This is a code block. +``` diff --git a/gb-src/test/integration/fixtures/markdown-out.html b/gb-src/test/integration/fixtures/markdown-out.html new file mode 100644 index 0000000000000..85a105b640923 --- /dev/null +++ b/gb-src/test/integration/fixtures/markdown-out.html @@ -0,0 +1,56 @@ + +

    This is a heading with italic

    + + + +

    This is a paragraph with a link, bold, and strikethrough.

    + + + +

    Preserve
    +line breaks please.

    + + + +

    Lists

    + + + +
    • A
    • Bulleted
      • Indented
    • List
    + + + +
    1. One
    2. Two
    3. Three
    + + + +

    Table

    + + + +
    First HeaderSecond Header
    Content from cell 1Content from cell 2
    Content in the first columnContent in the second column
    + + + +
    Table with empty cells.
    + + + +

    Quote

    + + + +

    First

    Second

    + + + +

    Code

    + + + +

    Inline code tags should work.

    + + + +
    This is a code block.
    + diff --git a/gb-src/test/integration/fixtures/ms-word-in.html b/gb-src/test/integration/fixtures/ms-word-in.html new file mode 100644 index 0000000000000..d65299287e642 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-in.html @@ -0,0 +1,203 @@ + +

    This is a +title

    + +

     

    + +

    This is a +subtitle

    + +

     

    + +

    This is a heading level 1

    + +

     

    + +

    This is a heading level 2

    + +

     

    + +

    This is a paragraph with a link.

    + +

     

    + +

    ·      A

    + +

    ·      Bulleted

    + +

    o   +Indented

    + +

    ·      List

    + +

     

    + +

    1      +One

    + +

    2      +Two

    + +

    3      +Three

    + +
    + +

     

    + +
    + + + + + + + + + + + + + + + + + +
    +

    One

    +
    +

    Two

    +
    +

    Three

    +
    +

    1

    +
    +

    2

    +
    +

    3

    +
    +

    I

    +
    +

    II

    +
    +

    III

    +
    + +

     

    + +

    An image:

    + +

     

    + +

    diff --git a/gb-src/test/integration/fixtures/ms-word-online-in.html b/gb-src/test/integration/fixtures/ms-word-online-in.html new file mode 100644 index 0000000000000..4f36dba345d32 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-online-in.html @@ -0,0 +1 @@ +

    This is a heading 

    This is a paragraph with a link. 

    • A 

    • Bulleted 

    • Indented 

    • List 

     

    1. One 

    1. Two 

    1. Three 

    One 

    Two 

    Three 

    1 

    2 

    3 

    I 

    II 

    III 

     

    An image: 

     

    diff --git a/gb-src/test/integration/fixtures/ms-word-online-out.html b/gb-src/test/integration/fixtures/ms-word-online-out.html new file mode 100644 index 0000000000000..798881e8c0151 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-online-out.html @@ -0,0 +1,27 @@ + +

    This is a heading 

    + + + +

    This is a paragraph with a link. 

    + + + +
    • A 
    • Bulleted 
    • Indented 
    • List 
    + + + +
    1. One 
    2. Two 
    3. Three 
    + + + +
    One Two Three 
    1 2 3 
    I II III 
    + + + +

    An image: 

    + + + +
    + diff --git a/gb-src/test/integration/fixtures/ms-word-out.html b/gb-src/test/integration/fixtures/ms-word-out.html new file mode 100644 index 0000000000000..427e12686ad62 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-out.html @@ -0,0 +1,59 @@ + +

    This is a +title

    + + + +

    This is a +subtitle

    + + + +

    This is a heading level 1

    + + + +

    This is a heading level 2

    + + + +

    This is a paragraph with a link.

    + + + +
    • A
    • Bulleted
      • Indented
    • List
    + + + +
    1. One
    2. Two
    3. Three
    + + + +
    + One + + Two + + Three +
    + 1 + + 2 + + 3 +
    + I + + II + + III +
    + + + +

    An image:

    + + + +
    + diff --git a/gb-src/test/integration/fixtures/ms-word-styled-in.html b/gb-src/test/integration/fixtures/ms-word-styled-in.html new file mode 100644 index 0000000000000..4405423ddf053 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-styled-in.html @@ -0,0 +1,27 @@ + + + + + + + + + +

    +Lorem +ipsum dolor sit amet, consectetur adipiscing elit  +

    +

    +
    + +

    +

    +Lorem +ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque +aliquet hendrerit auctor. Nam lobortis, est vel lacinia tincidunt, +purus tellus vehicula ex, nec pharetra justo dui sed lorem. Nam +congue laoreet massa, quis varius est tincidunt ut.

    diff --git a/gb-src/test/integration/fixtures/ms-word-styled-out.html b/gb-src/test/integration/fixtures/ms-word-styled-out.html new file mode 100644 index 0000000000000..2eef7365f9668 --- /dev/null +++ b/gb-src/test/integration/fixtures/ms-word-styled-out.html @@ -0,0 +1,15 @@ + +

    +Lorem +ipsum dolor sit amet, consectetur adipiscing elit  +

    + + + +

    +Lorem +ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque +aliquet hendrerit auctor. Nam lobortis, est vel lacinia tincidunt, +purus tellus vehicula ex, nec pharetra justo dui sed lorem. Nam +congue laoreet massa, quis varius est tincidunt ut.

    + diff --git a/gb-src/test/integration/fixtures/one-image-in.html b/gb-src/test/integration/fixtures/one-image-in.html new file mode 100644 index 0000000000000..f02e1327e2903 --- /dev/null +++ b/gb-src/test/integration/fixtures/one-image-in.html @@ -0,0 +1 @@ + diff --git a/gb-src/test/integration/fixtures/one-image-out.html b/gb-src/test/integration/fixtures/one-image-out.html new file mode 100644 index 0000000000000..defa0138370fa --- /dev/null +++ b/gb-src/test/integration/fixtures/one-image-out.html @@ -0,0 +1,3 @@ + +
    + diff --git a/gb-src/test/integration/fixtures/plain-in.html b/gb-src/test/integration/fixtures/plain-in.html new file mode 100644 index 0000000000000..0efc42aac1aaf --- /dev/null +++ b/gb-src/test/integration/fixtures/plain-in.html @@ -0,0 +1 @@ +test
    test

    test
    diff --git a/gb-src/test/integration/fixtures/plain-out.html b/gb-src/test/integration/fixtures/plain-out.html new file mode 100644 index 0000000000000..2cebc072c71b0 --- /dev/null +++ b/gb-src/test/integration/fixtures/plain-out.html @@ -0,0 +1,7 @@ + +

    test
    test

    + + + +

    test

    + diff --git a/gb-src/test/integration/fixtures/shortcode-caption-with-caption-link.html b/gb-src/test/integration/fixtures/shortcode-caption-with-caption-link.html new file mode 100644 index 0000000000000..4eb0f6c4f0c1f --- /dev/null +++ b/gb-src/test/integration/fixtures/shortcode-caption-with-caption-link.html @@ -0,0 +1 @@ +

    [caption id="attachment_122" align="alignnone" width="300"] test[/caption]

    diff --git a/gb-src/test/integration/fixtures/shortcode-caption-with-link.html b/gb-src/test/integration/fixtures/shortcode-caption-with-link.html new file mode 100644 index 0000000000000..3405b7ac1ecf0 --- /dev/null +++ b/gb-src/test/integration/fixtures/shortcode-caption-with-link.html @@ -0,0 +1 @@ +[caption id="attachment_754" align="alignnone" width="604"]Bell on Wharf Bell on wharf in San Francisco[/caption] diff --git a/gb-src/test/integration/fixtures/shortcode-caption.html b/gb-src/test/integration/fixtures/shortcode-caption.html new file mode 100644 index 0000000000000..5fc43da9e6c52 --- /dev/null +++ b/gb-src/test/integration/fixtures/shortcode-caption.html @@ -0,0 +1 @@ +

    [caption id="attachment_122" align="alignnone" width="300"] test[/caption]

    diff --git a/gb-src/test/integration/fixtures/two-images-in.html b/gb-src/test/integration/fixtures/two-images-in.html new file mode 100644 index 0000000000000..a7c885175789d --- /dev/null +++ b/gb-src/test/integration/fixtures/two-images-in.html @@ -0,0 +1,4 @@ + + + + diff --git a/gb-src/test/integration/fixtures/two-images-out.html b/gb-src/test/integration/fixtures/two-images-out.html new file mode 100644 index 0000000000000..4b96a052a52b7 --- /dev/null +++ b/gb-src/test/integration/fixtures/two-images-out.html @@ -0,0 +1,7 @@ + +
    + + + +
    + diff --git a/gb-src/test/integration/fixtures/wordpress-convert.html b/gb-src/test/integration/fixtures/wordpress-convert.html new file mode 100644 index 0000000000000..0fe1de21dc662 --- /dev/null +++ b/gb-src/test/integration/fixtures/wordpress-convert.html @@ -0,0 +1,38 @@ +

    Howdy

    + +

    This is a paragraph.

    +

    Preserve me!

    +

    More tag

    +

    +

    Shortcode

    +

    [gallery ids="1"]

    + + + +
    +
    Term
    +
    + Description. +
    +
    + + + +
      +
        +
      1. Item
      2. +
      +
    + + + +
    +

    Text.

    +
    + + + +
    +

    Heading

    +

    Text.

    +
    diff --git a/gb-src/test/integration/fixtures/wordpress-in.html b/gb-src/test/integration/fixtures/wordpress-in.html new file mode 100644 index 0000000000000..d9f6afb344e5f --- /dev/null +++ b/gb-src/test/integration/fixtures/wordpress-in.html @@ -0,0 +1,6 @@ +

    Howdy

    +

    This is a paragraph.

    +

    More tag

    +

    +

    Shortcode

    +

    [gallery ids="1"]

    diff --git a/gb-src/test/integration/fixtures/wordpress-out.html b/gb-src/test/integration/fixtures/wordpress-out.html new file mode 100644 index 0000000000000..6eab75f342def --- /dev/null +++ b/gb-src/test/integration/fixtures/wordpress-out.html @@ -0,0 +1,23 @@ + +

    Howdy

    + + + +

    This is a paragraph.

    + + + +

    More tag

    + + + + + + + +

    Shortcode

    + + + + + diff --git a/gb-src/test/integration/full-content/full-content.test.js b/gb-src/test/integration/full-content/full-content.test.js new file mode 100644 index 0000000000000..1a50c84a83450 --- /dev/null +++ b/gb-src/test/integration/full-content/full-content.test.js @@ -0,0 +1,253 @@ +/** + * External dependencies + */ +import { omit, startsWith, get } from 'lodash'; +import { format } from 'util'; + +/** + * WordPress dependencies + */ +import { + getBlockTypes, + parse, + serialize, + unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase +} from '@wordpress/blocks'; +import { parse as grammarParse } from '@wordpress/block-serialization-default-parser'; +import { registerCoreBlocks, __experimentalRegisterExperimentalCoreBlocks } from '@wordpress/block-library'; +import { //eslint-disable-line no-restricted-syntax + blockNameToFixtureBasename, + getAvailableBlockFixturesBasenames, + getBlockFixtureHTML, + getBlockFixtureJSON, + getBlockFixtureParsedJSON, + getBlockFixtureSerializedHTML, + writeBlockFixtureParsedJSON, + writeBlockFixtureJSON, + writeBlockFixtureSerializedHTML, +} from '@wordpress/e2e-tests/fixtures'; + +const blockBasenames = getAvailableBlockFixturesBasenames(); + +function normalizeParsedBlocks( blocks ) { + return blocks.map( ( block, index ) => { + // Clone and remove React-instance-specific stuff; also, attribute + // values that equal `undefined` will be removed. Validation issues + // add too much noise so they get removed as well. + block = JSON.parse( JSON.stringify( omit( block, 'validationIssues' ) ) ); + + // Change client IDs to a predictable value + block.clientId = '_clientId_' + index; + + // Recurse to normalize inner blocks + block.innerBlocks = normalizeParsedBlocks( block.innerBlocks ); + + return block; + } ); +} + +describe( 'full post content fixture', () => { + beforeAll( () => { + unstable__bootstrapServerSideBlockDefinitions( require( './server-registered.json' ) ); + const settings = { __experimentalEnableLegacyWidgetBlock: true, __experimentalEnableMenuBlock: true }; + // Load all hooks that modify blocks + require( '../../../packages/editor/src/hooks' ); + registerCoreBlocks(); + if ( process.env.GUTENBERG_PHASE === 2 ) { + __experimentalRegisterExperimentalCoreBlocks( settings ); + } + } ); + + blockBasenames.forEach( ( basename ) => { + it( basename, () => { + const { + filename: htmlFixtureFileName, + file: htmlFixtureContent, + } = getBlockFixtureHTML( basename ); + if ( htmlFixtureContent === null ) { + throw new Error( + `Missing fixture file: ${ htmlFixtureFileName }` + ); + } + + const { + filename: parsedJSONFixtureFileName, + file: parsedJSONFixtureContent, + } = getBlockFixtureParsedJSON( basename ); + const parserOutputActual = grammarParse( htmlFixtureContent ); + let parserOutputExpectedString; + if ( parsedJSONFixtureContent ) { + parserOutputExpectedString = parsedJSONFixtureContent; + } else if ( process.env.GENERATE_MISSING_FIXTURES ) { + parserOutputExpectedString = JSON.stringify( + parserOutputActual, + null, + 4 + ) + '\n'; + writeBlockFixtureParsedJSON( basename, parserOutputExpectedString ); + } else { + throw new Error( + `Missing fixture file: ${ parsedJSONFixtureFileName }` + ); + } + + const parserOutputExpected = JSON.parse( parserOutputExpectedString ); + try { + expect( + parserOutputActual + ).toEqual( parserOutputExpected ); + } catch ( err ) { + throw new Error( format( + "File '%s' does not match expected value:\n\n%s", + parsedJSONFixtureFileName, + err.message + ) ); + } + + const blocksActual = parse( htmlFixtureContent ); + + // Block validation may log errors during deprecation migration, + // unless explicitly handled from a valid block via isEligible. + // Match on basename for deprecated blocks fixtures to allow. + const isDeprecated = /__deprecated([-_]|$)/.test( basename ); + if ( isDeprecated ) { + /* eslint-disable no-console */ + console.warn.mockReset(); + console.error.mockReset(); + console.info.mockReset(); + /* eslint-enable no-console */ + } + + const blocksActualNormalized = normalizeParsedBlocks( blocksActual ); + const { + filename: jsonFixtureFileName, + file: jsonFixtureContent, + } = getBlockFixtureJSON( basename ); + + let blocksExpectedString; + + if ( jsonFixtureContent ) { + blocksExpectedString = jsonFixtureContent; + } else if ( process.env.GENERATE_MISSING_FIXTURES ) { + blocksExpectedString = JSON.stringify( + blocksActualNormalized, + null, + 4 + ) + '\n'; + writeBlockFixtureJSON( basename, blocksExpectedString ); + } else { + throw new Error( + `Missing fixture file: ${ jsonFixtureFileName }` + ); + } + + const blocksExpected = JSON.parse( blocksExpectedString ); + try { + expect( + blocksActualNormalized + ).toEqual( blocksExpected ); + } catch ( err ) { + throw new Error( format( + "File '%s' does not match expected value:\n\n%s", + jsonFixtureFileName, + err.message + ) ); + } + + // `serialize` doesn't have a trailing newline, but the fixture + // files should. + const serializedActual = serialize( blocksActual ) + '\n'; + const { + filename: serializedHTMLFileName, + file: serializedHTMLFixtureContent, + } = getBlockFixtureSerializedHTML( basename ); + + let serializedExpected; + if ( serializedHTMLFixtureContent ) { + serializedExpected = serializedHTMLFixtureContent; + } else if ( process.env.GENERATE_MISSING_FIXTURES ) { + serializedExpected = serializedActual; + writeBlockFixtureSerializedHTML( basename, serializedExpected ); + } else { + throw new Error( + `Missing fixture file: ${ serializedHTMLFileName }` + ); + } + + try { + expect( serializedActual ).toEqual( serializedExpected ); + } catch ( err ) { + throw new Error( format( + "File '%s' does not match expected value:\n\n%s", + serializedHTMLFileName, + err.message + ) ); + } + } ); + } ); + + it( 'should be present for each block', () => { + const errors = []; + + getBlockTypes() + .map( ( block ) => block.name ) + // We don't want tests for each oembed provider, which all have the same + // `save` functions and attributes. + // The `core/template` is not worth testing here because it's never saved, it's covered better in e2e tests. + .filter( ( name ) => name.indexOf( 'core-embed' ) !== 0 && name !== 'core/template' ) + .forEach( ( name ) => { + const nameToFilename = blockNameToFixtureBasename( name ); + const foundFixtures = blockBasenames + .filter( ( basename ) => ( + basename === nameToFilename || + startsWith( basename, nameToFilename + '__' ) + ) ) + .map( ( basename ) => { + const { + filename: htmlFixtureFileName, + } = getBlockFixtureHTML( basename ); + const { + file: jsonFixtureContent, + } = getBlockFixtureJSON( basename ); + // The parser output for this test. For missing files, + // JSON.parse( null ) === null. + const parserOutput = JSON.parse( + jsonFixtureContent, + ); + // The name of the first block that this fixture file + // contains (if any). + const firstBlock = get( parserOutput, [ '0', 'name' ], null ); + return { + filename: htmlFixtureFileName, + parserOutput, + firstBlock, + }; + } ) + .filter( ( fixture ) => fixture.parserOutput !== null ); + + if ( ! foundFixtures.length ) { + errors.push( format( + "Expected a fixture file called '%s.html' or '%s__*.html'.", + nameToFilename, + nameToFilename + ) ); + } + + foundFixtures.forEach( ( fixture ) => { + if ( name !== fixture.firstBlock ) { + errors.push( format( + "Expected fixture file '%s' to test the '%s' block.", + fixture.filename, + name + ) ); + } + } ); + } ); + + if ( errors.length ) { + throw new Error( + 'Problem(s) with fixture files:\n\n' + errors.join( '\n' ) + ); + } + } ); +} ); diff --git a/gb-src/test/integration/full-content/server-registered.json b/gb-src/test/integration/full-content/server-registered.json new file mode 100644 index 0000000000000..e11fe0283b44a --- /dev/null +++ b/gb-src/test/integration/full-content/server-registered.json @@ -0,0 +1 @@ +{"core\/archives":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"displayAsDropdown":{"type":"boolean","default":false},"showPostCounts":{"type":"boolean","default":false}}},"core\/block":{"attributes":{"ref":{"type":"number"}}},"core\/calendar":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"month":{"type":"integer"},"year":{"type":"integer"}}},"core\/categories":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"displayAsDropdown":{"type":"boolean","default":false},"showHierarchy":{"type":"boolean","default":false},"showPostCounts":{"type":"boolean","default":false}}},"core\/latest-comments":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"commentsToShow":{"type":"number","default":5,"minimum":1,"maximum":100},"displayAvatar":{"type":"boolean","default":true},"displayDate":{"type":"boolean","default":true},"displayExcerpt":{"type":"boolean","default":true}}},"core\/latest-posts":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"categories":{"type":"string"},"postsToShow":{"type":"number","default":5},"displayPostContent":{"type":"boolean","default":false},"displayPostContentRadio":{"type":"string","default":"excerpt"},"excerptLength":{"type":"number","default":55},"displayPostDate":{"type":"boolean","default":false},"postLayout":{"type":"string","default":"list"},"columns":{"type":"number","default":3},"order":{"type":"string","default":"desc"},"orderBy":{"type":"string","default":"date"}}},"core\/legacy-widget":{"attributes":{"identifier":{"type":"string"},"instance":{"type":"object"},"isCallbackWidget":{"type":"boolean"}}},"core\/navigation-menu":{"category":"layout","attributes":{"automaticallyAdd":{"type":"boolean","default":false}}},"core\/rss":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"columns":{"type":"number","default":2},"blockLayout":{"type":"string","default":"list"},"feedURL":{"type":"string","default":""},"itemsToShow":{"type":"number","default":5},"displayExcerpt":{"type":"boolean","default":false},"displayAuthor":{"type":"boolean","default":false},"displayDate":{"type":"boolean","default":false},"excerptLength":{"type":"number","default":55}}},"core\/search":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"label":{"type":"string","default":"Search"},"placeholder":{"type":"string","default":""},"buttonText":{"type":"string","default":"Search"}}},"core\/shortcode":{"attributes":{"text":{"type":"string","source":"html"}}},"core\/tag-cloud":{"attributes":{"align":{"type":"string","enum":["left","center","right","wide","full"]},"className":{"type":"string"},"taxonomy":{"type":"string","default":"post_tag"},"showTagCounts":{"type":"boolean","default":false}}}} \ No newline at end of file diff --git a/gb-src/test/integration/is-valid-block.test.js b/gb-src/test/integration/is-valid-block.test.js new file mode 100644 index 0000000000000..f10ef5f34d09f --- /dev/null +++ b/gb-src/test/integration/is-valid-block.test.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { isValidBlockContent } from '@wordpress/blocks'; +import { createElement } from '@wordpress/element'; + +describe( 'isValidBlockContent', () => { + beforeAll( () => { + // Load all hooks that modify blocks + require( '../../packages/editor/src/hooks' ); + } ); + + it( 'should use the namespace in the classname for non-core blocks', () => { + const valid = isValidBlockContent( + { + save: ( { attributes } ) => createElement( 'div', null, attributes.fruit ), + name: 'myplugin/fruit', + }, + { fruit: 'Bananas' }, + '
    Bananas
    ' + ); + + expect( valid ).toBe( true ); + } ); + + it( 'should include additional classes in block attributes', () => { + const valid = isValidBlockContent( + { + save: ( { attributes } ) => createElement( 'div', { + className: 'fruit', + }, attributes.fruit ), + name: 'myplugin/fruit', + }, + { + fruit: 'Bananas', + className: 'fresh', + }, + '
    Bananas
    ' + ); + + expect( valid ).toBe( true ); + } ); + + it( 'should not add a className if falsy', () => { + const valid = isValidBlockContent( + { + save: ( { attributes } ) => createElement( 'div', null, attributes.fruit ), + name: 'myplugin/fruit', + supports: { + className: false, + }, + }, + { fruit: 'Bananas' }, + '
    Bananas
    ' + ); + + expect( valid ).toBe( true ); + } ); +} ); diff --git a/gb-src/test/integration/non-matched-tags-handling.test.js b/gb-src/test/integration/non-matched-tags-handling.test.js new file mode 100644 index 0000000000000..71ce3a13b6137 --- /dev/null +++ b/gb-src/test/integration/non-matched-tags-handling.test.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { + pasteHandler, + unregisterBlockType, +} from '@wordpress/blocks'; +import { registerCoreBlocks } from '@wordpress/block-library'; + +describe( 'Handling of non matched tags in block transforms', () => { + beforeAll( () => { + // Load all hooks that modify blocks + require( '../../packages/editor/src/hooks' ); + registerCoreBlocks(); + } ); + it( 'correctly pastes preformatted tag even if preformatted block is removed', () => { + unregisterBlockType( 'core/preformatted' ); + const simplePreformattedResult = pasteHandler( { + HTML: '
    Pre
    ', + mode: 'AUTO', + } ); + + expect( simplePreformattedResult ).toHaveLength( 1 ); + expect( simplePreformattedResult[ 0 ].name ).toBe( 'core/paragraph' ); + expect( simplePreformattedResult[ 0 ].attributes.content ).toBe( 'Pre' ); + + const codeResult = pasteHandler( { + HTML: '
    code
    ', + mode: 'AUTO', + } ); + + expect( codeResult ).toHaveLength( 1 ); + expect( codeResult[ 0 ].name ).toBe( 'core/code' ); + expect( codeResult[ 0 ].attributes.content ).toBe( 'code' ); + expect( console ).toHaveLogged(); + } ); +} ); diff --git a/gb-src/test/integration/shortcode-converter.test.js b/gb-src/test/integration/shortcode-converter.test.js new file mode 100644 index 0000000000000..2f0e5ab632fa7 --- /dev/null +++ b/gb-src/test/integration/shortcode-converter.test.js @@ -0,0 +1,104 @@ +/** + * WordPress dependencies + */ +import { registerCoreBlocks } from '@wordpress/block-library'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import segmentHTMLToShortcodeBlock from '../../packages/blocks/src/api/raw-handling/shortcode-converter'; + +describe( 'segmentHTMLToShortcodeBlock', () => { + beforeAll( () => { + registerCoreBlocks(); + } ); + + it( 'should convert a standalone shortcode between two paragraphs', () => { + const original = `

    Foo

    + +[foo bar="apple"] + +

    Bar

    `; + const transformed = segmentHTMLToShortcodeBlock( original, 0 ); + expect( transformed ).toHaveLength( 3 ); + expect( transformed[ 0 ] ).toBe( `

    Foo

    + +` ); + const expectedBlock = createBlock( 'core/shortcode', { + text: '[foo bar="apple"]', + } ); + // clientId will always be random. + expectedBlock.clientId = transformed[ 1 ].clientId; + expect( transformed[ 1 ] ).toEqual( expectedBlock ); + expect( transformed[ 2 ] ).toBe( ` + +

    Bar

    ` ); + } ); + + it( 'should convert two instances of the same shortcode', () => { + const original = `

    [foo one]

    +

    [foo two]

    `; + + const transformed = segmentHTMLToShortcodeBlock( original, 0 ); + expect( transformed[ 0 ] ).toEqual( '

    ' ); + const firstExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo one]', + } ); + // clientId will always be random. + firstExpectedBlock.clientId = transformed[ 1 ].clientId; + expect( transformed[ 1 ] ).toEqual( firstExpectedBlock ); + expect( transformed[ 2 ] ).toEqual( `

    +

    ` ); + const secondExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo two]', + } ); + // clientId will always be random. + secondExpectedBlock.clientId = transformed[ 3 ].clientId; + expect( transformed[ 3 ] ).toEqual( secondExpectedBlock ); + expect( transformed[ 4 ] ).toEqual( '

    ' ); + expect( transformed ).toHaveLength( 5 ); + } ); + + it( 'should convert four instances of the same shortcode', () => { + const original = `

    [foo one]

    +

    [foo two]

    +

    [foo three]

    +

    [foo four]

    `; + + const transformed = segmentHTMLToShortcodeBlock( original, 0 ); + expect( transformed[ 0 ] ).toEqual( '

    ' ); + const firstExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo one]', + } ); + // clientId will always be random. + firstExpectedBlock.clientId = transformed[ 1 ].clientId; + expect( transformed[ 1 ] ).toEqual( firstExpectedBlock ); + expect( transformed[ 2 ] ).toEqual( `

    +

    ` ); + const secondExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo two]', + } ); + // clientId will always be random. + secondExpectedBlock.clientId = transformed[ 3 ].clientId; + expect( transformed[ 3 ] ).toEqual( secondExpectedBlock ); + expect( transformed[ 4 ] ).toEqual( `

    +

    ` ); + const thirdExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo three]', + } ); + // clientId will always be random. + thirdExpectedBlock.clientId = transformed[ 5 ].clientId; + expect( transformed[ 5 ] ).toEqual( thirdExpectedBlock ); + expect( transformed[ 6 ] ).toEqual( `

    +

    ` ); + const fourthExpectedBlock = createBlock( 'core/shortcode', { + text: '[foo four]', + } ); + // clientId will always be random. + fourthExpectedBlock.clientId = transformed[ 7 ].clientId; + expect( transformed[ 7 ] ).toEqual( fourthExpectedBlock ); + expect( transformed[ 8 ] ).toEqual( '

    ' ); + expect( transformed ).toHaveLength( 9 ); + } ); +} ); diff --git a/gb-src/test/native/__mocks__/react-native-aztec/index.js b/gb-src/test/native/__mocks__/react-native-aztec/index.js new file mode 100644 index 0000000000000..6c4c0f0d1353b --- /dev/null +++ b/gb-src/test/native/__mocks__/react-native-aztec/index.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import React from 'react'; + +class AztecView extends React.Component { + blur = () => { + } + + focus = () => { + } + + isFocused = () => { + return false; + } + + render() { + return ( <> ); + } +} + +export default AztecView; diff --git a/gb-src/test/native/__mocks__/react-native-gutenberg-bridge/index.js b/gb-src/test/native/__mocks__/react-native-gutenberg-bridge/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-hr/index.js b/gb-src/test/native/__mocks__/react-native-hr/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-modal/index.js b/gb-src/test/native/__mocks__/react-native-modal/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-recyclerview-list/index.js b/gb-src/test/native/__mocks__/react-native-recyclerview-list/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-safe-area/index.js b/gb-src/test/native/__mocks__/react-native-safe-area/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-svg/index.js b/gb-src/test/native/__mocks__/react-native-svg/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/react-native-video/index.js b/gb-src/test/native/__mocks__/react-native-video/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/gb-src/test/native/__mocks__/styleMock.js b/gb-src/test/native/__mocks__/styleMock.js new file mode 100644 index 0000000000000..182af41388dc0 --- /dev/null +++ b/gb-src/test/native/__mocks__/styleMock.js @@ -0,0 +1,69 @@ +module.exports = { + //Mock block paragraph style with minimum height + blockText: { + minHeight: 50, + }, + container: { + height: 44, + }, + toolbar: { + height: 44, + }, + blockContainerFocused: { + paddingBottom: 0, + }, + blockCode: { + fontFamily: 'serif', + }, + 'rich-text': { + fontFamily: 'serif', + minHeight: 30, + }, + 'rich-text-placeholder': { + color: 'gray', + }, + 'block-editor-plain-text': { + fontFamily: 'serif', + }, + addBlockButton: { + color: '', + }, + blockHolderFocused: { + borderColor: 'gray', + }, + 'wp-block-heading': { + minHeight: 60, + }, + 'editor-rich-text': { + minHeight: 50, + }, + 'editor-plain-text': { + fontFamily: 'serif', + }, + background: { + maxWidth: 512, + }, + modalItem: { + paddingLeft: 8, + paddingRight: 8, + }, + content: { + paddingLeft: 8, + paddingRight: 8, + }, + modalIconWrapper: { + width: 104, + }, + modalIcon: { + fill: 'gray', + }, + icon: { + fill: 'gray', + }, + iconRetry: { + fill: 'gray', + }, + iconUploading: { + fill: 'gray', + }, +}; diff --git a/gb-src/test/native/babel.config.js b/gb-src/test/native/babel.config.js new file mode 100644 index 0000000000000..7fa61e90babc8 --- /dev/null +++ b/gb-src/test/native/babel.config.js @@ -0,0 +1,57 @@ +module.exports = function( api ) { + api.cache( true ); + return { + presets: [ + 'module:metro-react-native-babel-preset', + ], + plugins: [ + '@babel/plugin-proposal-async-generator-functions', + '@babel/plugin-transform-runtime', + [ + 'react-native-platform-specific-extensions', + { + extensions: [ + 'css', + 'scss', + 'sass', + ], + }, + ], + ], + overrides: [ + { // Transforms JSX into JS function calls and use `createElement` instead of the default `React.createElement` + plugins: [ + [ + '@babel/plugin-transform-react-jsx', + { + pragma: 'createElement', + pragmaFrag: 'Fragment', + }, + ], + ], + exclude: /node_modules\/react-native/, + }, + { // Auto-add `import { createElement } from '@wordpress/element';` when JSX is found + plugins: [ + [ + '../../packages/babel-plugin-import-jsx-pragma', + { + scopeVariable: 'createElement', + scopeVariableFrag: 'Fragment', + source: '@wordpress/element', + isDefault: false, + }, + ], + ], + exclude: /node_modules\/react-native/, + }, + ], + env: { + development: { + plugins: [ + '@babel/transform-react-jsx-source', + ], + }, + }, + }; +}; diff --git a/gb-src/test/native/enzyme.config.js b/gb-src/test/native/enzyme.config.js new file mode 100644 index 0000000000000..da4dd944515db --- /dev/null +++ b/gb-src/test/native/enzyme.config.js @@ -0,0 +1,6 @@ +/** + * External dependencies + */ +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +configure( { adapter: new Adapter() } ); diff --git a/gb-src/test/native/jest.config.js b/gb-src/test/native/jest.config.js new file mode 100644 index 0000000000000..6ff9370be4893 --- /dev/null +++ b/gb-src/test/native/jest.config.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +const glob = require( 'glob' ).sync; + +const defaultPlatform = 'android'; +const rnPlatform = process.env.TEST_RN_PLATFORM || defaultPlatform; +if ( process.env.TEST_RN_PLATFORM ) { + // eslint-disable-next-line no-console + console.log( 'Setting RN platform to: ' + process.env.TEST_RN_PLATFORM ); +} else { + // eslint-disable-next-line no-console + console.log( 'Setting RN platform to: default (' + defaultPlatform + ')' ); +} + +const configPath = 'test/native'; + +const transpiledPackageNames = glob( '../../packages/*/src/index.js' ) + .map( ( fileName ) => fileName.split( '/' )[ 3 ] ); + +module.exports = { + verbose: true, + rootDir: '../../', + // Automatically clear mock calls and instances between every test + clearMocks: true, + preset: 'react-native', + setupFiles: [ + '/' + configPath + '/setup.js', + '/' + configPath + '/enzyme.config.js', + ], + testEnvironment: 'jsdom', + testMatch: [ + '**/test/*.native.[jt]s?(x)', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/wordpress/', + '/__device-tests__/', + ], + testURL: 'http://localhost/', + moduleDirectories: [ 'node_modules' ], + moduleNameMapper: { + // Mock the CSS modules. See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets + '\\.(scss)$': '/' + configPath + '/__mocks__/styleMock.js', + [ `@wordpress\\/(${ transpiledPackageNames.join( '|' ) })$` ]: '/packages/$1/src', + }, + haste: { + defaultPlatform: rnPlatform, + platforms: [ + 'android', + 'ios', + 'native', + ], + hasteImplModulePath: '/node_modules/react-native/jest/hasteImpl.js', + providesModuleNodeModules: [ + 'react-native', + 'react-native-svg', + ], + }, + transformIgnorePatterns: [ + // This is required for now to have jest transform some of our modules + // See: https://github.com/wordpress-mobile/gutenberg-mobile/pull/257#discussion_r234978268 + // There is no overloading in jest so we need to rewrite the config from react-native-jest-preset: + // https://github.com/facebook/react-native/blob/master/jest-preset.json#L20 + 'node_modules/(?!(simple-html-tokenizer|(jest-)?react-native|react-clone-referenced-element))', + ], + snapshotSerializers: [ + 'enzyme-to-json/serializer', + ], + reporters: [ 'default', 'jest-junit' ], +}; diff --git a/gb-src/test/native/setup.js b/gb-src/test/native/setup.js new file mode 100644 index 0000000000000..cfd2417a11ad9 --- /dev/null +++ b/gb-src/test/native/setup.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { NativeModules } from 'react-native'; + +jest.mock( 'react-native-gutenberg-bridge', () => { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + subscribeParentGetHtml: jest.fn(), + subscribeParentToggleHTMLMode: jest.fn(), + subscribeSetTitle: jest.fn(), + subscribeSetFocusOnTitle: jest.fn(), + subscribeUpdateHtml: jest.fn(), + subscribeMediaAppend: jest.fn(), + editorDidMount: jest.fn(), + subscribeMediaUpload: jest.fn(), + requestMediaPickFromMediaLibrary: jest.fn(), + requestMediaPickFromDeviceLibrary: jest.fn(), + requestMediaPickFromDeviceCamera: jest.fn(), + }; +} ); + +jest.mock( 'react-native-modal', () => () => 'Modal' ); + +jest.mock( 'react-native-hr', () => () => 'Hr' ); + +jest.mock( 'react-native-svg', () => { + return { + Svg: () => 'Svg', + Path: () => 'Path', + Circle: () => 'Circle', + G: () => 'G', + Polygon: () => 'Polygon', + Rect: () => 'Rect', + }; +} ); + +jest.mock( 'react-native-safe-area', () => { + const addEventListener = jest.fn(); + addEventListener.mockReturnValue( { remove: () => {} } ); + return { + getSafeAreaInsetsForRootView: () => { + return new Promise( ( accept ) => { + accept( { safeAreaInsets: { bottom: 34 } } ); + } ); + }, + addEventListener, + removeEventListener: jest.fn(), + }; +} ); + +jest.mock( 'react-native-recyclerview-list' ); + +if ( ! global.window.matchMedia ) { + global.window.matchMedia = () => ( { + matches: false, + addListener: () => {}, + removeListener: () => {}, + } ); +} + +// Overwrite some native module mocks from `react-native` jest preset: +// https://github.com/facebook/react-native/blob/master/jest/setup.js +// to fix issue "TypeError: Cannot read property 'Commands' of undefined" +// raised when calling focus or blur on a native component +const mockNativeModules = { + UIManager: { + ...NativeModules.UIManager, + getViewManagerConfig: jest.fn( () => ( { Commands: {} } ) ), + }, +}; + +Object.keys( mockNativeModules ).forEach( ( module ) => { + try { + jest.doMock( module, () => mockNativeModules[ module ] ); // needed by FacebookSDK-test + } catch ( error ) { + jest.doMock( module, () => mockNativeModules[ module ], { virtual: true } ); + } +} ); diff --git a/gb-src/test/unit/__mocks__/@wordpress/block-library.js b/gb-src/test/unit/__mocks__/@wordpress/block-library.js new file mode 100644 index 0000000000000..72c9351ad7dad --- /dev/null +++ b/gb-src/test/unit/__mocks__/@wordpress/block-library.js @@ -0,0 +1,5 @@ +// Make sure the classic block is registered. +window.wp = window.wp || {}; +window.wp.oldEditor = {}; + +export * from '../../../../packages/block-library/src'; diff --git a/gb-src/test/unit/config/gutenberg-phase.js b/gb-src/test/unit/config/gutenberg-phase.js new file mode 100644 index 0000000000000..1b6117b3236a6 --- /dev/null +++ b/gb-src/test/unit/config/gutenberg-phase.js @@ -0,0 +1,6 @@ +global.process.env = { + ...global.process.env, + // Inject the `GUTENBERG_PHASE` global, used for feature flagging. + // eslint-disable-next-line @wordpress/gutenberg-phase + GUTENBERG_PHASE: parseInt( process.env.npm_package_config_GUTENBERG_PHASE, 10 ), +}; diff --git a/gb-src/test/unit/jest.config.js b/gb-src/test/unit/jest.config.js new file mode 100644 index 0000000000000..6ad2de76e69ef --- /dev/null +++ b/gb-src/test/unit/jest.config.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +const glob = require( 'glob' ).sync; + +// Finds all packages which are transpiled with Babel to force Jest to use their source code. +const transpiledPackageNames = glob( 'packages/*/src/index.js' ) + .map( ( fileName ) => fileName.split( '/' )[ 1 ] ); + +module.exports = { + rootDir: '../../', + moduleNameMapper: { + [ `@wordpress\\/(${ transpiledPackageNames.join( '|' ) })$` ]: 'packages/$1/src', + }, + preset: '@wordpress/jest-preset-default', + setupFiles: [ + '/test/unit/config/gutenberg-phase.js', + ], + testURL: 'http://localhost', + testPathIgnorePatterns: [ + '/\.git/', + '/node_modules/', + '/packages/e2e-tests', + '/wordpress/', + '/.*/build/', + '/.*/build-module/', + '/.+\.native\.js$', + ], + transform: { + '^.+\\.[jt]sx?$': '/test/unit/scripts/babel-transformer.js', + }, +}; diff --git a/gb-src/test/unit/scripts/babel-transformer.js b/gb-src/test/unit/scripts/babel-transformer.js new file mode 100644 index 0000000000000..8c433576cedf6 --- /dev/null +++ b/gb-src/test/unit/scripts/babel-transformer.js @@ -0,0 +1,40 @@ +const fs = require( 'fs' ); +const babelJest = require( 'babel-jest' ); +const babelJestTransformer = babelJest.createTransformer(); + +module.exports = { + // This transformer extends the babel-jest transformer. + ...babelJestTransformer, + + /** + * A cache key generator that extends babel-jest's cache key generator, + * but adds some additional handling for invalidating the cache when a + * change to a block.json file is made. + * + * @param {string} src The source of the file being transformed. + * @param {string} filename The filename of the file being transformed. + * @param {...any} args Any other args passed to the function. + * + * @return {string} The cache key for the file. + */ + getCacheKey( src, filename, ...args ) { + const isBlockIndex = /block-library[\/\\]src[\/\\].+[\/\\]index\.js/.test( filename ); + + if ( ! isBlockIndex ) { + return babelJestTransformer.getCacheKey( src, filename, ...args ); + } + + const blockJSONFilename = filename.replace( 'index.js', 'block.json' ); + + if ( ! fs.existsSync( blockJSONFilename ) ) { + return babelJestTransformer.getCacheKey( src, filename, ...args ); + } + + // If the file is a block index file and there's a block json file, generate the + // jest cache key for this file by concatenating the block index and block json + // src together. This will result in the cache key changing and the cache being + // invalidated for the index when any changes to the json are made. + const blockJSONSrc = fs.readFileSync( blockJSONFilename ); + return babelJestTransformer.getCacheKey( `${ src }\n${ blockJSONSrc }`, filename, ...args ); + }, +}; diff --git a/gb-src/tsconfig.json b/gb-src/tsconfig.json new file mode 100644 index 0000000000000..cee321c53693e --- /dev/null +++ b/gb-src/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "checkJs": true, + "jsx": "preserve", + "lib": ["dom", "esnext"], + "module": "commonjs", + "noEmit": true, + "resolveJsonModule": true, + "target": "esnext", + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ + + /* Additional Checks */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + }, + "include": [ + "./packages/url/**/*.js" + ], + "exclude": [ + "./packages/**/test/**", + "./packages/**/build/**", + "./packages/**/build-module/**" + ] +} diff --git a/gb-src/vendor/.gitignore b/gb-src/vendor/.gitignore new file mode 100644 index 0000000000000..d6b7ef32c8478 --- /dev/null +++ b/gb-src/vendor/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/gb-src/webpack.config.js b/gb-src/webpack.config.js new file mode 100644 index 0000000000000..f1b9c578192eb --- /dev/null +++ b/gb-src/webpack.config.js @@ -0,0 +1,155 @@ +/** + * External dependencies + */ +const { DefinePlugin } = require( 'webpack' ); +const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); +const postcss = require( 'postcss' ); +const { get, escapeRegExp, compact } = require( 'lodash' ); +const { basename, sep } = require( 'path' ); + +/** + * WordPress dependencies + */ +const CustomTemplatedPathPlugin = require( '@wordpress/custom-templated-path-webpack-plugin' ); +const LibraryExportDefaultPlugin = require( '@wordpress/library-export-default-webpack-plugin' ); +const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' ); +const { camelCaseDash } = require( '@wordpress/scripts/utils' ); + +/** + * Internal dependencies + */ +const { dependencies } = require( './package' ); + +const { + NODE_ENV: mode = 'development', + WP_DEVTOOL: devtool = ( mode === 'production' ? false : 'source-map' ), +} = process.env; + +const WORDPRESS_NAMESPACE = '@wordpress/'; + +const gutenbergPackages = Object.keys( dependencies ) + .filter( ( packageName ) => packageName.startsWith( WORDPRESS_NAMESPACE ) ) + .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); + +module.exports = { + mode, + entry: gutenbergPackages.reduce( ( memo, packageName ) => { + const name = camelCaseDash( packageName ); + memo[ name ] = `./packages/${ packageName }`; + return memo; + }, {} ), + output: { + devtoolNamespace: 'wp', + filename: './build/[basename]/index.js', + path: __dirname, + library: [ 'wp', '[name]' ], + libraryTarget: 'this', + }, + module: { + rules: compact( [ + mode !== 'production' && { + test: /\.js$/, + use: require.resolve( 'source-map-loader' ), + enforce: 'pre', + }, + ] ), + }, + plugins: [ + new DefinePlugin( { + // Inject the `GUTENBERG_PHASE` global, used for feature flagging. + 'process.env.GUTENBERG_PHASE': JSON.stringify( parseInt( process.env.npm_package_config_GUTENBERG_PHASE, 10 ) || 1 ), + 'process.env.FORCE_REDUCED_MOTION': JSON.stringify( process.env.FORCE_REDUCED_MOTION ), + } ), + new CustomTemplatedPathPlugin( { + basename( path, data ) { + let rawRequest; + + const entryModule = get( data, [ 'chunk', 'entryModule' ], {} ); + switch ( entryModule.type ) { + case 'javascript/auto': + rawRequest = entryModule.rawRequest; + break; + + case 'javascript/esm': + rawRequest = entryModule.rootModule.rawRequest; + break; + } + + if ( rawRequest ) { + return basename( rawRequest ); + } + + return path; + }, + } ), + new LibraryExportDefaultPlugin( [ + 'api-fetch', + 'deprecated', + 'dom-ready', + 'redux-routine', + 'token-list', + 'server-side-render', + 'shortcode', + ].map( camelCaseDash ) ), + new CopyWebpackPlugin( + gutenbergPackages.map( ( packageName ) => ( { + from: `./packages/${ packageName }/build-style/*.css`, + to: `./build/${ packageName }/`, + flatten: true, + transform: ( content ) => { + if ( mode === 'production' ) { + return postcss( [ + require( 'cssnano' )( { + preset: [ 'default', { + discardComments: { + removeAll: true, + }, + } ], + } ), + ] ) + .process( content, { from: 'src/app.css', to: 'dest/app.css' } ) + .then( ( result ) => result.css ); + } + return content; + }, + } ) ) + ), + new CopyWebpackPlugin( [ + { + from: './packages/block-library/src/**/index.php', + test: new RegExp( `([\\w-]+)${ escapeRegExp( sep ) }index\\.php$` ), + to: 'build/block-library/blocks/[1].php', + transform( content ) { + content = content.toString(); + + // Within content, search for any function definitions. For + // each, replace every other reference to it in the file. + return content + .match( /^function [^\(]+/gm ) + .reduce( ( result, functionName ) => { + // Trim leading "function " prefix from match. + functionName = functionName.slice( 9 ); + + // Prepend the Gutenberg prefix, substituting any + // other core prefix (e.g. "wp_"). + return result.replace( + new RegExp( functionName, 'g' ), + ( match ) => 'gutenberg_' + match.replace( /^wp_/, '' ) + ); + }, content ) + // The core blocks override procedure takes place in + // the init action default priority to ensure that core + // blocks would have been registered already. Since the + // blocks implementations occur at the default priority + // and due to WordPress hooks behavior not considering + // mutations to the same priority during another's + // callback, the Gutenberg build blocks are modified + // to occur at a later priority. + .replace( /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, '$1, 20' ); + }, + }, + ] ), + new DependencyExtractionWebpackPlugin( { injectPolyfill: true } ), + ], + devtool, +}; diff --git a/package-lock.json b/package-lock.json index 173e18f10accd..ba5432ef4956f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2005,57 +2005,45 @@ } }, "@wordpress/a11y": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-2.5.1.tgz", - "integrity": "sha512-aZidVNquAYrGopHLUr96vhX47kteAw41EC3zAsP2wxMkmTd2q2olCAaBo12a4rbj0JwNyw4Pq2uqis5hZVuMXw==", + "version": "file:gb-src/packages/a11y", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/dom-ready": "^2.5.1" + "@wordpress/dom-ready": "file:gb-src/packages/dom-ready" } }, "@wordpress/annotations": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@wordpress/annotations/-/annotations-1.7.4.tgz", - "integrity": "sha512-lLncHIhqdyqvKUp8HUdq+ooZ29jvZKlW8iDhoJA2w3YcZTrBhwbMHmb5Ga5pT0iEZd92eXWc5mKYSSk7CcchWg==", + "version": "file:gb-src/packages/annotations", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/data": "^4.9.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/rich-text": "^3.7.4", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", "lodash": "^4.17.15", "rememo": "^3.0.0", "uuid": "^3.3.2" } }, "@wordpress/api-fetch": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-3.6.4.tgz", - "integrity": "sha512-mZnC3Ixa+3AbuH3Sk1f1ect0ZyQTY+VHXpyPbmGGbFBSqozxTX9bbwdcJ5Z28STDWhzIW+jLPY406vZ8rRcg6A==", + "version": "file:gb-src/packages/api-fetch", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/i18n": "^3.6.1", - "@wordpress/url": "^2.8.2" + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/url": "file:gb-src/packages/url" } }, "@wordpress/autop": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-2.5.1.tgz", - "integrity": "sha512-vU5b7eBo6qgZk20pOJAHRcs6x3YkMfruIcEPWDbf3pfsnaZPWjXFA39l2MTR5kRNDaiOyLLFzuVb/XunYc71oA==", + "version": "file:gb-src/packages/autop", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/babel-plugin-import-jsx-pragma": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-plugin-import-jsx-pragma/-/babel-plugin-import-jsx-pragma-2.3.0.tgz", - "integrity": "sha512-b45c4x1+OvQm1f6egrBruO8eVF4bRVRZ8ojM1ttDcMi+K/qXfun3J6O8xXpSnA5eeNCZaJL3DhIk/aoNBbpwzw==", + "version": "file:gb-src/packages/babel-plugin-import-jsx-pragma", "dev": true }, "@wordpress/babel-preset-default": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-4.6.2.tgz", - "integrity": "sha512-UCR35dagwrlWLYFWqEOG8ZrCUYQl1dHcvnMgiW+MvAnAZwiGV041M0ApbiyKQOUvM6Q2RnRzAsSl+vgdeO9y5w==", + "version": "file:gb-src/packages/babel-preset-default", "dev": true, "requires": { "@babel/core": "^7.4.5", @@ -2065,76 +2053,55 @@ "@babel/plugin-transform-runtime": "^7.4.4", "@babel/preset-env": "^7.4.5", "@babel/runtime": "^7.4.5", - "@wordpress/babel-plugin-import-jsx-pragma": "^2.3.0", - "@wordpress/browserslist-config": "^2.6.0", - "@wordpress/element": "^2.8.2", + "@wordpress/babel-plugin-import-jsx-pragma": "file:gb-src/packages/babel-plugin-import-jsx-pragma", + "@wordpress/browserslist-config": "file:gb-src/packages/browserslist-config", + "@wordpress/element": "file:gb-src/packages/element", "core-js": "^3.1.4" - }, - "dependencies": { - "@wordpress/element": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.8.2.tgz", - "integrity": "sha512-pwy2qvbkNIoB+XTwtvsAKP/pmCoqFq3vyH9uakNIVHJF/DzOwwLS3y5I5hHTuNYmmgFuGALQVZlUVVkujRpWfg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@wordpress/escape-html": "^1.5.1", - "lodash": "^4.17.15", - "react": "^16.9.0", - "react-dom": "^16.9.0" - } - } } }, "@wordpress/blob": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-2.5.1.tgz", - "integrity": "sha512-Nd4f51Er4/KqHzAMOY83EBA9b3mdAP8MToxlzy2WOfK2WzoiVVRjNJ2pE9edc17yBwkQmpvPFDyfEWI7MHYxIw==", + "version": "file:gb-src/packages/blob", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/block-directory": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@wordpress/block-directory/-/block-directory-1.0.8.tgz", - "integrity": "sha512-CWky27JNiag196/mPmaZaZ7GjNhg2KyPM441VDNFADLXowdPQpO+HyWf0PHxh5FVjW2vlxlMZt/MVnFS2nNTXw==", - "requires": { - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/block-editor": "^3.2.7", - "@wordpress/blocks": "^6.7.3", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", + "version": "file:gb-src/packages/block-directory", + "requires": { + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", "lodash": "^4.17.15" } }, "@wordpress/block-editor": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-3.2.7.tgz", - "integrity": "sha512-PGFJ+Od5L34X/wP8/vco4OsOnE/IyAI1+KGGLOFeANrjP9HHeKJfY0lnWyvNdwK9PEXdaQD/HvTejWXTdXyt8g==", + "version": "file:gb-src/packages/block-editor", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/a11y": "^2.5.1", - "@wordpress/blob": "^2.5.1", - "@wordpress/blocks": "^6.7.3", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/dom": "^2.5.3", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/html-entities": "^2.5.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/rich-text": "^3.7.4", - "@wordpress/token-list": "^1.6.1", - "@wordpress/url": "^2.8.2", - "@wordpress/viewport": "^2.8.2", - "@wordpress/wordcount": "^2.6.2", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/dom": "file:gb-src/packages/dom", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/html-entities": "file:gb-src/packages/html-entities", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", + "@wordpress/token-list": "file:gb-src/packages/token-list", + "@wordpress/url": "file:gb-src/packages/url", + "@wordpress/viewport": "file:gb-src/packages/viewport", + "@wordpress/wordcount": "file:gb-src/packages/wordcount", "classnames": "^2.2.5", "diff": "^3.5.0", "dom-scroll-into-view": "^1.2.1", @@ -2151,32 +2118,30 @@ } }, "@wordpress/block-library": { - "version": "2.9.13", - "resolved": "https://registry.npmjs.org/@wordpress/block-library/-/block-library-2.9.13.tgz", - "integrity": "sha512-PDmImLyk2dRq7iLWYJ8plk0cU8T97zYccXXBp2uq+7bfbaQNwrKkSI/2xjcduzx/kqfYpj3fVBqcYjotMTOGqQ==", + "version": "file:gb-src/packages/block-library", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/a11y": "^2.5.1", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/autop": "^2.5.1", - "@wordpress/blob": "^2.5.1", - "@wordpress/block-editor": "^3.2.7", - "@wordpress/blocks": "^6.7.3", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/core-data": "^2.7.5", - "@wordpress/data": "^4.9.2", - "@wordpress/date": "^3.5.0", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/editor": "^9.7.9", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/rich-text": "^3.7.4", - "@wordpress/server-side-render": "^1.3.6", - "@wordpress/url": "^2.8.2", - "@wordpress/viewport": "^2.8.2", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/autop": "file:gb-src/packages/autop", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/core-data": "file:gb-src/packages/core-data", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/date": "file:gb-src/packages/date", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/editor": "file:gb-src/packages/editor", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", + "@wordpress/server-side-render": "file:gb-src/packages/server-side-render", + "@wordpress/url": "file:gb-src/packages/url", + "@wordpress/viewport": "file:gb-src/packages/viewport", "classnames": "^2.2.5", "fast-average-color": "4.3.0", "lodash": "^4.17.15", @@ -2187,31 +2152,27 @@ } }, "@wordpress/block-serialization-default-parser": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-3.4.1.tgz", - "integrity": "sha512-6AcBDAmfQUJZYouANhkuamdObkO7PZHYdebtHNCYGXolSwUg5ceBuHR/geFiSwETDUSKvKAyZLI9/86a52K+NQ==", + "version": "file:gb-src/packages/block-serialization-default-parser", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/blocks": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-6.7.3.tgz", - "integrity": "sha512-gMBtBOmWNC5zRrz8dQVEc/iFbEpShA4wBRk3iCv2D9kBYJvQHeFQda5QxyLA083EF2RNbpdlg9wgDhb7CKNJQg==", + "version": "file:gb-src/packages/blocks", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/autop": "^2.5.1", - "@wordpress/blob": "^2.5.1", - "@wordpress/block-serialization-default-parser": "^3.4.1", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", - "@wordpress/dom": "^2.5.3", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/html-entities": "^2.5.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/shortcode": "^2.4.1", + "@wordpress/autop": "file:gb-src/packages/autop", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/block-serialization-default-parser": "file:gb-src/packages/block-serialization-default-parser", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/dom": "file:gb-src/packages/dom", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/html-entities": "file:gb-src/packages/html-entities", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/shortcode": "file:gb-src/packages/shortcode", "hpq": "^1.3.0", "lodash": "^4.17.15", "rememo": "^3.0.0", @@ -2222,27 +2183,23 @@ } }, "@wordpress/browserslist-config": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-2.6.0.tgz", - "integrity": "sha512-vRgzGoxhcNVChBP30XZlyK4w6r/9ZpO+Fi1dzmButp31lUEb1pT5WBxTIQl3HE0JZ9YTEJ00WWGO5sjGi5MHZA==", + "version": "file:gb-src/packages/browserslist-config", "dev": true }, "@wordpress/components": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-8.3.4.tgz", - "integrity": "sha512-PakzBBAN9MtVWAVHwFflfsqiXkIMRqY8++g/pJnPInOIFGdSi6Am4yOlqfRyNixycXRAPaAUz7HutajXxed3Qg==", + "version": "file:gb-src/packages/components", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/a11y": "^2.5.1", - "@wordpress/compose": "^3.7.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/dom": "^2.5.3", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/rich-text": "^3.7.4", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/dom": "file:gb-src/packages/dom", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", "classnames": "^2.2.5", "clipboard": "^2.0.1", "dom-scroll-into-view": "^1.2.1", @@ -2259,101 +2216,61 @@ } }, "@wordpress/compose": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-3.7.2.tgz", - "integrity": "sha512-UIc+egHGpfviUZewGFkSKW8xf3xA8VSCTkdCKXzdDgb+j3aqU7STgXeoOwfYDOxZP1xUgWqX1bxDOoZxU+s47Q==", + "version": "file:gb-src/packages/compose", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/element": "^2.8.2", - "@wordpress/is-shallow-equal": "^1.6.1", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", "lodash": "^4.17.15" - }, - "dependencies": { - "@wordpress/element": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.8.2.tgz", - "integrity": "sha512-pwy2qvbkNIoB+XTwtvsAKP/pmCoqFq3vyH9uakNIVHJF/DzOwwLS3y5I5hHTuNYmmgFuGALQVZlUVVkujRpWfg==", - "requires": { - "@babel/runtime": "^7.4.4", - "@wordpress/escape-html": "^1.5.1", - "lodash": "^4.17.15", - "react": "^16.9.0", - "react-dom": "^16.9.0" - } - } } }, "@wordpress/core-data": { - "version": "2.7.5", - "resolved": "https://registry.npmjs.org/@wordpress/core-data/-/core-data-2.7.5.tgz", - "integrity": "sha512-Onc3rmkHr6Pr9PYJX8YgA5CJlSxY86xCHrBUFOgWOOpWV9RS3zSiZPGnhwTFAjd15P9Hfe2ZwXGpvxBnhBLI9g==", + "version": "file:gb-src/packages/core-data", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/data": "^4.9.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/url": "^2.8.2", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/url": "file:gb-src/packages/url", "equivalent-key-map": "^0.2.2", "lodash": "^4.17.15", "rememo": "^3.0.0" } }, "@wordpress/custom-templated-path-webpack-plugin": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@wordpress/custom-templated-path-webpack-plugin/-/custom-templated-path-webpack-plugin-1.5.0.tgz", - "integrity": "sha512-OU7CtzLqjj30FUPA/T5fYxjrMkYoxIgwcKuxNTzmXfN+lXW8GR3jG3IYeVA61ruQnqlfhuLXql6KZgQU6/JRPA==", + "version": "file:gb-src/packages/custom-templated-path-webpack-plugin", "dev": true, "requires": { "escape-string-regexp": "^1.0.5" } }, "@wordpress/data": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-4.9.2.tgz", - "integrity": "sha512-wdFkR4yvkoRje7PBWpBOcI2AE6VM36yfZc1aSgX8XEFDWUXVovrIKPmhVEcW3b/E0ZxeR5BaIEnCYQbJKvDF+g==", + "version": "file:gb-src/packages/data", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/element": "^2.8.2", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/priority-queue": "^1.3.1", - "@wordpress/redux-routine": "^3.6.2", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/priority-queue": "file:gb-src/packages/priority-queue", + "@wordpress/redux-routine": "file:gb-src/packages/redux-routine", "equivalent-key-map": "^0.2.2", "is-promise": "^2.1.0", "lodash": "^4.17.15", "redux": "^4.0.0", "turbo-combine-reducers": "^1.0.2" - }, - "dependencies": { - "@wordpress/element": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.8.2.tgz", - "integrity": "sha512-pwy2qvbkNIoB+XTwtvsAKP/pmCoqFq3vyH9uakNIVHJF/DzOwwLS3y5I5hHTuNYmmgFuGALQVZlUVVkujRpWfg==", - "requires": { - "@babel/runtime": "^7.4.4", - "@wordpress/escape-html": "^1.5.1", - "lodash": "^4.17.15", - "react": "^16.9.0", - "react-dom": "^16.9.0" - } - } } }, "@wordpress/data-controls": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@wordpress/data-controls/-/data-controls-1.3.4.tgz", - "integrity": "sha512-Wt2sNMKAX/CKeeeQkdPtolTDcP1nPjvPnXiKLL116h89i4nJ3gBgS6PA31XEeDFuj/tq8HiYeMbzfijKhJyHsA==", + "version": "file:gb-src/packages/data-controls", "requires": { - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/data": "^4.9.2" + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/data": "file:gb-src/packages/data" } }, "@wordpress/date": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-3.5.0.tgz", - "integrity": "sha512-eg3/o7Z0SgeXtPhJxMuxSQUk9Lx0GGgQptpnparsFzgW69KgibLmGuDqepEhfClhKLeIR3kDVf/m1HRPSosbRA==", + "version": "file:gb-src/packages/date", "requires": { "@babel/runtime": "^7.4.4", "moment": "^2.22.1", @@ -2361,9 +2278,7 @@ } }, "@wordpress/dependency-extraction-webpack-plugin": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-2.0.0.tgz", - "integrity": "sha512-RJSbpnLBndYu02jrzbk0MTUi4uoOiEHXYSe9s8YM/40yJnUm6k1PvrytDG6VxFbjFARCCOzKgU70L+/xeC4pLQ==", + "version": "file:gb-src/packages/dependency-extraction-webpack-plugin", "dev": true, "requires": { "json2php": "^0.0.4", @@ -2372,70 +2287,60 @@ } }, "@wordpress/deprecated": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-2.6.1.tgz", - "integrity": "sha512-nwCZ5pfFCZRwpEOMOVJcPZ6dmcaUfINehfZwPZA/6wK6Ol5sfc5MP22zmz30LFGsP4yqfgSugYDLA2LLAnuWqg==", + "version": "file:gb-src/packages/deprecated", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/hooks": "^2.6.0" + "@wordpress/hooks": "file:gb-src/packages/hooks" } }, "@wordpress/dom": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-2.5.3.tgz", - "integrity": "sha512-fsByosxDHTpFwH6M86fB9XwECqxtbPCVOTyXZ7wyEI0Q9YPngCQmMW4q/GGHLON2FoA6lq23v6pQayHvbiXJrA==", + "version": "file:gb-src/packages/dom", "requires": { "@babel/runtime": "^7.4.4", "lodash": "^4.17.15" } }, "@wordpress/dom-ready": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-2.5.1.tgz", - "integrity": "sha512-HPEPWdShm/xJjQJVOOWpehvt4wtK0W5D/X0wpLfWAQjPOsTMTKnlRsRL3vTjIlgTrVZan1rv+3ZJv7AwH/1U5g==", + "version": "file:gb-src/packages/dom-ready", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/e2e-test-utils": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils/-/e2e-test-utils-2.4.3.tgz", - "integrity": "sha512-I/83+QhF1E4anCMy+ZqkX8S9v6TKhDvitbYgx2DQNfRFPsf07KWFun2lttWI7+apZ081JRSBKT+5SCoU121iSA==", + "version": "file:gb-src/packages/e2e-test-utils", "dev": true, "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/url": "^2.8.2", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/url": "file:gb-src/packages/url", "lodash": "^4.17.15", "node-fetch": "^1.7.3" } }, "@wordpress/edit-post": { - "version": "3.8.13", - "resolved": "https://registry.npmjs.org/@wordpress/edit-post/-/edit-post-3.8.13.tgz", - "integrity": "sha512-gHnDpaT3DXvQL6i1btOcgl79ZhwCT4v3wY5X+gOebAyffISuBCUYftHDQPwZO7uixFiO0usKUsILkCUA+ATqYw==", + "version": "file:gb-src/packages/edit-post", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/a11y": "^2.5.1", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/block-editor": "^3.2.7", - "@wordpress/block-library": "^2.9.13", - "@wordpress/blocks": "^6.7.3", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/core-data": "^2.7.5", - "@wordpress/data": "^4.9.2", - "@wordpress/editor": "^9.7.9", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/media-utils": "^1.2.4", - "@wordpress/notices": "^1.8.2", - "@wordpress/nux": "^3.7.4", - "@wordpress/plugins": "^2.7.2", - "@wordpress/url": "^2.8.2", - "@wordpress/viewport": "^2.8.2", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/block-library": "file:gb-src/packages/block-library", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/core-data": "file:gb-src/packages/core-data", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/editor": "file:gb-src/packages/editor", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/media-utils": "file:gb-src/packages/media-utils", + "@wordpress/notices": "file:gb-src/packages/notices", + "@wordpress/nux": "file:gb-src/packages/nux", + "@wordpress/plugins": "file:gb-src/packages/plugins", + "@wordpress/url": "file:gb-src/packages/url", + "@wordpress/viewport": "file:gb-src/packages/viewport", "classnames": "^2.2.5", "lodash": "^4.17.15", "memize": "^1.0.5", @@ -2444,38 +2349,36 @@ } }, "@wordpress/editor": { - "version": "9.7.9", - "resolved": "https://registry.npmjs.org/@wordpress/editor/-/editor-9.7.9.tgz", - "integrity": "sha512-nMfxuV+u3SmcohBvTD8NrswnsVW9tKD0H8T12nC4yq/F4EyW/I0/0ps/NkcEj/CyaAfk/SNKMF8fcDD64LNuLw==", + "version": "file:gb-src/packages/editor", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/autop": "^2.5.1", - "@wordpress/blob": "^2.5.1", - "@wordpress/block-directory": "^1.0.8", - "@wordpress/block-editor": "^3.2.7", - "@wordpress/blocks": "^6.7.3", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/core-data": "^2.7.5", - "@wordpress/data": "^4.9.2", - "@wordpress/data-controls": "^1.3.4", - "@wordpress/date": "^3.5.0", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", - "@wordpress/html-entities": "^2.5.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/media-utils": "^1.2.4", - "@wordpress/notices": "^1.8.2", - "@wordpress/nux": "^3.7.4", - "@wordpress/rich-text": "^3.7.4", - "@wordpress/server-side-render": "^1.3.6", - "@wordpress/url": "^2.8.2", - "@wordpress/viewport": "^2.8.2", - "@wordpress/wordcount": "^2.6.2", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/autop": "file:gb-src/packages/autop", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/block-directory": "file:gb-src/packages/block-directory", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/core-data": "file:gb-src/packages/core-data", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/data-controls": "file:gb-src/packages/data-controls", + "@wordpress/date": "file:gb-src/packages/date", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/html-entities": "file:gb-src/packages/html-entities", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/media-utils": "file:gb-src/packages/media-utils", + "@wordpress/notices": "file:gb-src/packages/notices", + "@wordpress/nux": "file:gb-src/packages/nux", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", + "@wordpress/server-side-render": "file:gb-src/packages/server-side-render", + "@wordpress/url": "file:gb-src/packages/url", + "@wordpress/viewport": "file:gb-src/packages/viewport", + "@wordpress/wordcount": "file:gb-src/packages/wordcount", "classnames": "^2.2.5", "lodash": "^4.17.15", "memize": "^1.0.5", @@ -2486,29 +2389,23 @@ } }, "@wordpress/element": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.8.2.tgz", - "integrity": "sha512-pwy2qvbkNIoB+XTwtvsAKP/pmCoqFq3vyH9uakNIVHJF/DzOwwLS3y5I5hHTuNYmmgFuGALQVZlUVVkujRpWfg==", + "version": "file:gb-src/packages/element", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/escape-html": "^1.5.1", + "@wordpress/escape-html": "file:gb-src/packages/escape-html", "lodash": "^4.17.15", "react": "^16.9.0", "react-dom": "^16.9.0" } }, "@wordpress/escape-html": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-1.5.1.tgz", - "integrity": "sha512-C4chmaS4US5+SDQKcYuqU+MZJa41o7Wq7IwDsqIQfnrqTEhjk5JBFQm2M0slWOti9hry9CAGlmorp5XvE/eiyg==", + "version": "file:gb-src/packages/escape-html", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/eslint-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-3.1.0.tgz", - "integrity": "sha512-i/eNTWll3OH7rFukG2pNZXlOl0xihnuxg/2maEEMGzLS8dA8TEwyzCUXCqKycpOLR9sqODhdWFjeQBAPIjpZHg==", + "version": "file:gb-src/packages/eslint-plugin", "dev": true, "requires": { "babel-eslint": "^10.0.2", @@ -2533,43 +2430,35 @@ } }, "@wordpress/format-library": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@wordpress/format-library/-/format-library-1.9.7.tgz", - "integrity": "sha512-F2lF8Gr2iG6ggCqIxkTrSGuMpvWArZ0c5ntXqdGpavcpCWdOlmjxuWeijFSKX+wd9oS+gLrSZj9H3IDxar/opQ==", + "version": "file:gb-src/packages/format-library", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/block-editor": "^3.2.7", - "@wordpress/components": "^8.3.4", - "@wordpress/dom": "^2.5.3", - "@wordpress/element": "^2.8.2", - "@wordpress/html-entities": "^2.5.0", - "@wordpress/i18n": "^3.6.1", - "@wordpress/keycodes": "^2.6.2", - "@wordpress/rich-text": "^3.7.4", - "@wordpress/url": "^2.8.2", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/dom": "file:gb-src/packages/dom", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/html-entities": "file:gb-src/packages/html-entities", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", + "@wordpress/url": "file:gb-src/packages/url", "lodash": "^4.17.15" } }, "@wordpress/hooks": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-2.6.0.tgz", - "integrity": "sha512-93THIW9EklVkJLN3wr8PUdWqttRu/wGZgXUrM6fqqOGwm4uCL4S+jQe800kwZioRHvgW5moNLrvSdfanQ4QyEQ==", + "version": "file:gb-src/packages/hooks", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/html-entities": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-2.5.0.tgz", - "integrity": "sha512-7TKaJKkOX2Tas0OyXNPz1kA2my1Z804weBf2RsPLiNXm593JDsf6Em8z1TA4mXtn7FO2ZAKTj/3yRemKK4PhnA==", + "version": "file:gb-src/packages/html-entities", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/i18n": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-3.6.1.tgz", - "integrity": "sha512-hrmhW53XZ5VAK9+DQjjTHoshnZHWGpj7w4q7jl7KMhONgzRtSdDaHtgNLHuwCZPAJzTFJbExU6b02lyrRogXzg==", + "version": "file:gb-src/packages/i18n", "requires": { "@babel/runtime": "^7.4.4", "gettext-parser": "^1.3.1", @@ -2580,17 +2469,13 @@ } }, "@wordpress/is-shallow-equal": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-1.6.1.tgz", - "integrity": "sha512-0vrTVuE6S/lmW4QKklTNqwSUyvxeCPRq02gKk6ViTw3I/QhE65g7dLRwwEDZ+42obSNA7XousutZ3YoN/NpPbA==", + "version": "file:gb-src/packages/is-shallow-equal", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/jest-console": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-3.3.1.tgz", - "integrity": "sha512-3lDSBHq6sgH3LWoAAqDnt9CzT2iJ80ezHciVKfOwbfpR7dPxUXVD4fUau/xdqdzfICJXvCUgN5oTih9DtS29AQ==", + "version": "file:gb-src/packages/jest-console", "dev": true, "requires": { "@babel/runtime": "^7.4.4", @@ -2599,12 +2484,10 @@ } }, "@wordpress/jest-preset-default": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-5.1.1.tgz", - "integrity": "sha512-QQHXjM03A39XRfZb9lhFVw960yjrj3Yr3BFuJczPMk8hWCM0hqW+yYyMLfMny+yW2VJ6WizVFzoS6L/RH97+LA==", + "version": "file:gb-src/packages/jest-preset-default", "dev": true, "requires": { - "@wordpress/jest-console": "^3.3.1", + "@wordpress/jest-console": "file:gb-src/packages/jest-console", "babel-jest": "^24.7.1", "enzyme": "^3.9.0", "enzyme-adapter-react-16": "^1.10.0", @@ -2612,19 +2495,15 @@ } }, "@wordpress/keycodes": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-2.6.2.tgz", - "integrity": "sha512-xWwyTYdyesZFc6V3RDn2Gjkx5bfa5fGDGrX0WcxT+vA2oEMFqLA2JzUqEzylR7aX3uFfsdDEp0GbPl8ipcLChw==", + "version": "file:gb-src/packages/keycodes", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/i18n": "^3.6.1", + "@wordpress/i18n": "file:gb-src/packages/i18n", "lodash": "^4.17.15" } }, "@wordpress/library-export-default-webpack-plugin": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@wordpress/library-export-default-webpack-plugin/-/library-export-default-webpack-plugin-1.4.1.tgz", - "integrity": "sha512-x8QCmwfBRO7lUkjQ+KSUSrn8viteaz7eplMI+viRmhWcXV8xkYlWmft4XzoSqkw/f1Xwqj1QZ3tGmw4v5vZQ0A==", + "version": "file:gb-src/packages/library-export-default-webpack-plugin", "dev": true, "requires": { "lodash": "^4.17.15", @@ -2632,102 +2511,72 @@ } }, "@wordpress/list-reusable-blocks": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@wordpress/list-reusable-blocks/-/list-reusable-blocks-1.8.6.tgz", - "integrity": "sha512-3azmulTNg4QzFeSkONs6DKSJjUM3s3uB9Dluv7jPW81i46cVFckEPkdnTyDsWn/QhKqVvdrsZ790T2hO6QKK1Q==", + "version": "file:gb-src/packages/list-reusable-blocks", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", "lodash": "^4.17.15" } }, "@wordpress/media-utils": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@wordpress/media-utils/-/media-utils-1.2.4.tgz", - "integrity": "sha512-xw5WWHOiBOvpF7ENnX8Squh6BxEY79rtmFV1iDaMuHd0R48i4JLFd5KWDf8q8O+RQp7hZfUwcpN5HD6BlFHvKw==", + "version": "file:gb-src/packages/media-utils", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/blob": "^2.5.1", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", "lodash": "^4.17.15" } }, "@wordpress/notices": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-1.8.2.tgz", - "integrity": "sha512-E6HBMlZ9hsdNYvcMwa7rkI/gYTUnCYUIZxNfcq331xB/4Pb8UD4fcSMm7fi3k8CfRsy8QQT9efvBbz3HgbeVuQ==", + "version": "file:gb-src/packages/notices", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/a11y": "^2.5.1", - "@wordpress/data": "^4.9.2", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/data": "file:gb-src/packages/data", "lodash": "^4.17.15" } }, "@wordpress/npm-package-json-lint-config": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-2.1.0.tgz", - "integrity": "sha512-NSwcK7GtlmW5O5ZMG7elRKBa9sPws17Sadjlztig6ShOuhlLFeHYk99tUenpmJ/PYOZex4fSJ5e9mqjPyKunjw==", + "version": "file:gb-src/packages/npm-package-json-lint-config", "dev": true }, "@wordpress/nux": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@wordpress/nux/-/nux-3.7.4.tgz", - "integrity": "sha512-ZTK+UN4QoaeFvcpa4hTBh24YtY2RpwJJLworaQAnNA9XATTk8FsakKFrLhE0eKfC6s5Uk/u9jDpNvA+9rlND/Q==", + "version": "file:gb-src/packages/nux", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/components": "^8.3.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", "lodash": "^4.17.15", "rememo": "^3.0.0" } }, "@wordpress/plugins": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@wordpress/plugins/-/plugins-2.7.2.tgz", - "integrity": "sha512-+1LjcOP3OEE7qYjNbBEkcJDascZZfZMCW4bvKLFu5y2ipvE9h/QmAy1nEy8iyQ//srfP3DpGxuI62j3DcGzg2Q==", + "version": "file:gb-src/packages/plugins", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/element": "^2.8.2", - "@wordpress/hooks": "^2.6.0", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/hooks": "file:gb-src/packages/hooks", "lodash": "^4.17.15" - }, - "dependencies": { - "@wordpress/element": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.8.2.tgz", - "integrity": "sha512-pwy2qvbkNIoB+XTwtvsAKP/pmCoqFq3vyH9uakNIVHJF/DzOwwLS3y5I5hHTuNYmmgFuGALQVZlUVVkujRpWfg==", - "requires": { - "@babel/runtime": "^7.4.4", - "@wordpress/escape-html": "^1.5.1", - "lodash": "^4.17.15", - "react": "^16.9.0", - "react-dom": "^16.9.0" - } - } } }, "@wordpress/priority-queue": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-1.3.1.tgz", - "integrity": "sha512-KQ242PoGFpttTqoz2UZwnOeecHtSQ4qaVf45WSgjYvi1tTIqTNu4OlYiO70XmRDmHj2UcUvL02Ie7czTfbK2sw==", + "version": "file:gb-src/packages/priority-queue", "requires": { "@babel/runtime": "^7.4.4" } }, "@wordpress/redux-routine": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-3.6.2.tgz", - "integrity": "sha512-+WxQzKzaFKLfioFxIyT8J4NqOfra4DvFK1jdAqmhLdqfdY9wuSFsJHlbt5hF5fVx32n7lidx/mGkP1ysmigkqA==", + "version": "file:gb-src/packages/redux-routine", "requires": { "@babel/runtime": "^7.4.4", "is-promise": "^2.1.0", @@ -2736,19 +2585,17 @@ } }, "@wordpress/rich-text": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-3.7.4.tgz", - "integrity": "sha512-4oCZAP42/Kpt2Vetrwzsaii703eR6dXpdLlVvOULa2fcc3v659RAalgNMcI7t1XJ01hYgmf2Fy7vHX2TSKU9sQ==", + "version": "file:gb-src/packages/rich-text", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/element": "^2.8.2", - "@wordpress/escape-html": "^1.5.1", - "@wordpress/hooks": "^2.6.0", - "@wordpress/is-shallow-equal": "^1.6.1", - "@wordpress/keycodes": "^2.6.2", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/escape-html": "file:gb-src/packages/escape-html", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", "classnames": "^2.2.5", "lodash": "^4.17.15", "memize": "^1.0.5", @@ -2756,16 +2603,14 @@ } }, "@wordpress/scripts": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-5.1.0.tgz", - "integrity": "sha512-S5fu9MIYLhkfLFkRWgBB1bSCKiPByEBDFxP+d7jzwNAZGZhHTTbAFvYEtEGQTXkEMCXW0p2oF233Sc6wY/tmjQ==", + "version": "file:gb-src/packages/scripts", "dev": true, "requires": { - "@wordpress/babel-preset-default": "^4.6.2", - "@wordpress/dependency-extraction-webpack-plugin": "^2.0.0", - "@wordpress/eslint-plugin": "^3.1.0", - "@wordpress/jest-preset-default": "^5.1.1", - "@wordpress/npm-package-json-lint-config": "^2.1.0", + "@wordpress/babel-preset-default": "file:gb-src/packages/babel-preset-default", + "@wordpress/dependency-extraction-webpack-plugin": "file:gb-src/packages/dependency-extraction-webpack-plugin", + "@wordpress/eslint-plugin": "file:gb-src/packages/eslint-plugin", + "@wordpress/jest-preset-default": "file:gb-src/packages/jest-preset-default", + "@wordpress/npm-package-json-lint-config": "file:gb-src/packages/npm-package-json-lint-config", "babel-jest": "^24.7.1", "babel-loader": "^8.0.5", "chalk": "^2.4.2", @@ -2858,25 +2703,21 @@ } }, "@wordpress/server-side-render": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@wordpress/server-side-render/-/server-side-render-1.3.6.tgz", - "integrity": "sha512-9Aj6LoSt52NNgr8hg8Mp1AGf8hN4p9yiL/qXCEcinzXl4UXl0kTIOqBc6ucODhpMbiWR3K0mBpyvxm28xjPzAw==", + "version": "file:gb-src/packages/server-side-render", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/api-fetch": "^3.6.4", - "@wordpress/components": "^8.3.4", - "@wordpress/data": "^4.9.2", - "@wordpress/deprecated": "^2.6.1", - "@wordpress/element": "^2.8.2", - "@wordpress/i18n": "^3.6.1", - "@wordpress/url": "^2.8.2", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/url": "file:gb-src/packages/url", "lodash": "^4.17.15" } }, "@wordpress/shortcode": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-2.4.1.tgz", - "integrity": "sha512-OcbUufn1Fq57V5Qxk4pFajR4+Q69VMCdJg0bB+/7qNZfJrx+vP2Av88SzhqnweQGWnBaNV5P0BFiLmu0Inpyyg==", + "version": "file:gb-src/packages/shortcode", "requires": { "@babel/runtime": "^7.4.4", "lodash": "^4.17.15", @@ -2884,38 +2725,30 @@ } }, "@wordpress/token-list": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-1.6.1.tgz", - "integrity": "sha512-6ro0Tfv8+uWlGEAHSrk+98ue1Do0DjWigDLgbC/fRyNaFMohPDbsoTSfDsN+qG6oPnz7icynHVuGtZNdikcVnA==", + "version": "file:gb-src/packages/token-list", "requires": { "@babel/runtime": "^7.4.4", "lodash": "^4.17.15" } }, "@wordpress/url": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-2.8.2.tgz", - "integrity": "sha512-adTOs94K5G1riTQveIH/pJoDmFGegzBWFSDe/YzhP6mg64AGOWLQLwi0xzfIyoFC4d+kchx4fCyFTqYfcCbRVg==", + "version": "file:gb-src/packages/url", "requires": { "@babel/runtime": "^7.4.4", "qs": "^6.5.2" } }, "@wordpress/viewport": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@wordpress/viewport/-/viewport-2.8.2.tgz", - "integrity": "sha512-4bj4Gpj4J08JPD/DRoJokwOHTNLCt/hr/mtKkCUBk+jGPNT/ENCK7jO7aBrL7GMsxJvfjJwWCc/UgNfYNdJOjg==", + "version": "file:gb-src/packages/viewport", "requires": { "@babel/runtime": "^7.4.4", - "@wordpress/compose": "^3.7.2", - "@wordpress/data": "^4.9.2", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/data": "file:gb-src/packages/data", "lodash": "^4.17.15" } }, "@wordpress/wordcount": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-2.6.2.tgz", - "integrity": "sha512-5BDYv9kodD/YHbr9lWM1g4eat2/t/1zpRSVhiacNFkKMr+JbqrYKIU/n9kDxHupSE4ZJuuTp+lDilwUJKC3e5A==", + "version": "file:gb-src/packages/wordcount", "requires": { "@babel/runtime": "^7.4.4", "lodash": "^4.17.15" diff --git a/package.json b/package.json index 13f6770f180c5..01481e3d28eaa 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,10 @@ "last 2 Opera versions" ], "devDependencies": { - "@wordpress/custom-templated-path-webpack-plugin": "1.5.0", - "@wordpress/e2e-test-utils": "2.4.3", - "@wordpress/library-export-default-webpack-plugin": "1.4.1", - "@wordpress/scripts": "5.1.0", + "@wordpress/custom-templated-path-webpack-plugin": "file:gb-src/packages/custom-templated-path-webpack-plugin", + "@wordpress/e2e-test-utils": "file:gb-src/packages/e2e-test-utils", + "@wordpress/library-export-default-webpack-plugin": "file:gb-src/packages/library-export-default-webpack-plugin", + "@wordpress/scripts": "file:gb-src/packages/scripts", "autoprefixer": "^9.8.6", "check-node-version": "4.0.3", "copy-webpack-plugin": "^5.1.2", @@ -71,48 +71,48 @@ }, "dependencies": { "@babel/polyfill": "7.4.4", - "@wordpress/a11y": "2.5.1", - "@wordpress/annotations": "1.7.4", - "@wordpress/api-fetch": "3.6.4", - "@wordpress/autop": "2.5.1", - "@wordpress/blob": "2.5.1", - "@wordpress/block-editor": "3.2.7", - "@wordpress/block-library": "2.9.13", - "@wordpress/block-serialization-default-parser": "3.4.1", - "@wordpress/blocks": "6.7.3", - "@wordpress/components": "8.3.4", - "@wordpress/compose": "3.7.2", - "@wordpress/core-data": "2.7.5", - "@wordpress/data": "4.9.2", - "@wordpress/data-controls": "1.3.4", - "@wordpress/date": "3.5.0", - "@wordpress/deprecated": "2.6.1", - "@wordpress/dom": "2.5.3", - "@wordpress/dom-ready": "2.5.1", - "@wordpress/edit-post": "3.8.13", - "@wordpress/editor": "9.7.9", - "@wordpress/element": "2.8.2", - "@wordpress/escape-html": "1.5.1", - "@wordpress/format-library": "1.9.7", - "@wordpress/hooks": "2.6.0", - "@wordpress/html-entities": "2.5.0", - "@wordpress/i18n": "3.6.1", - "@wordpress/is-shallow-equal": "1.6.1", - "@wordpress/keycodes": "2.6.2", - "@wordpress/list-reusable-blocks": "1.8.6", - "@wordpress/media-utils": "1.2.4", - "@wordpress/notices": "1.8.2", - "@wordpress/nux": "3.7.4", - "@wordpress/plugins": "2.7.2", - "@wordpress/priority-queue": "1.3.1", - "@wordpress/redux-routine": "3.6.2", - "@wordpress/rich-text": "3.7.4", - "@wordpress/server-side-render": "1.3.6", - "@wordpress/shortcode": "2.4.1", - "@wordpress/token-list": "1.6.1", - "@wordpress/url": "2.8.2", - "@wordpress/viewport": "2.8.2", - "@wordpress/wordcount": "2.6.2", + "@wordpress/a11y": "file:gb-src/packages/a11y", + "@wordpress/annotations": "file:gb-src/packages/annotations", + "@wordpress/api-fetch": "file:gb-src/packages/api-fetch", + "@wordpress/autop": "file:gb-src/packages/autop", + "@wordpress/blob": "file:gb-src/packages/blob", + "@wordpress/block-editor": "file:gb-src/packages/block-editor", + "@wordpress/block-library": "file:gb-src/packages/block-library", + "@wordpress/block-serialization-default-parser": "file:gb-src/packages/block-serialization-default-parser", + "@wordpress/blocks": "file:gb-src/packages/blocks", + "@wordpress/components": "file:gb-src/packages/components", + "@wordpress/compose": "file:gb-src/packages/compose", + "@wordpress/core-data": "file:gb-src/packages/core-data", + "@wordpress/data": "file:gb-src/packages/data", + "@wordpress/data-controls": "file:gb-src/packages/data-controls", + "@wordpress/date": "file:gb-src/packages/date", + "@wordpress/deprecated": "file:gb-src/packages/deprecated", + "@wordpress/dom": "file:gb-src/packages/dom", + "@wordpress/dom-ready": "file:gb-src/packages/dom-ready", + "@wordpress/edit-post": "file:gb-src/packages/edit-post", + "@wordpress/editor": "file:gb-src/packages/editor", + "@wordpress/element": "file:gb-src/packages/element", + "@wordpress/escape-html": "file:gb-src/packages/escape-html", + "@wordpress/format-library": "file:gb-src/packages/format-library", + "@wordpress/hooks": "file:gb-src/packages/hooks", + "@wordpress/html-entities": "file:gb-src/packages/html-entities", + "@wordpress/i18n": "file:gb-src/packages/i18n", + "@wordpress/is-shallow-equal": "file:gb-src/packages/is-shallow-equal", + "@wordpress/keycodes": "file:gb-src/packages/keycodes", + "@wordpress/list-reusable-blocks": "file:gb-src/packages/list-reusable-blocks", + "@wordpress/media-utils": "file:gb-src/packages/media-utils", + "@wordpress/notices": "file:gb-src/packages/notices", + "@wordpress/nux": "file:gb-src/packages/nux", + "@wordpress/plugins": "file:gb-src/packages/plugins", + "@wordpress/priority-queue": "file:gb-src/packages/priority-queue", + "@wordpress/redux-routine": "file:gb-src/packages/redux-routine", + "@wordpress/rich-text": "file:gb-src/packages/rich-text", + "@wordpress/server-side-render": "file:gb-src/packages/server-side-render", + "@wordpress/shortcode": "file:gb-src/packages/shortcode", + "@wordpress/token-list": "file:gb-src/packages/token-list", + "@wordpress/url": "file:gb-src/packages/url", + "@wordpress/viewport": "file:gb-src/packages/viewport", + "@wordpress/wordcount": "file:gb-src/packages/wordcount", "backbone": "1.4.0", "clipboard": "2.0.4", "element-closest": "^2.0.2", @@ -134,6 +134,7 @@ "whatwg-fetch": "3.0.0" }, "scripts": { + "postinstall": "cd gb-src && npm ci --ignore-scripts && npm rebuild node-sass", "build": "grunt build", "build:dev": "grunt build --dev", "dev": "grunt watch --dev", diff --git a/tools/gutenberg/build-packages.js b/tools/gutenberg/build-packages.js new file mode 100644 index 0000000000000..42facec4322ea --- /dev/null +++ b/tools/gutenberg/build-packages.js @@ -0,0 +1,50 @@ +/* jshint node:true */ +/* jshint esversion: 6 */ + +/** + * Builds the `@wordpress/*` packages in `gb-src/packages` so that they can be + * consumed through the `file:` references in package.json. + * + * This produces the `build`, `build-module` and `build-style` directories for + * each package using the Gutenberg build script and its own locked toolchain + * (installed into `gb-src/node_modules` by the root `postinstall` script). + */ + +/** + * External dependencies + */ +const { spawnSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const GB_DIR = path.resolve( __dirname, '../../gb-src' ); +const PACKAGES_DIR = path.join( GB_DIR, 'packages' ); +const BUILD_DIRS = [ 'build', 'build-module', 'build-style' ]; + +if ( ! fs.existsSync( path.join( GB_DIR, 'node_modules' ) ) ) { + console.error( 'gb-src/node_modules is missing. Run `npm install` in the project root first.' ); + process.exit( 1 ); +} + +// Remove any previous build output so that deleted source files do not linger. +fs.readdirSync( PACKAGES_DIR, { withFileTypes: true } ).forEach( ( entry ) => { + if ( ! entry.isDirectory() ) { + return; + } + + BUILD_DIRS.forEach( ( buildDir ) => { + const packageName = entry.name; + fs.rmSync( path.join( PACKAGES_DIR, packageName, buildDir ), { recursive: true, force: true } ); + } ); +} ); + +const result = spawnSync( + process.execPath, + [ path.join( GB_DIR, 'bin/packages/build.js' ) ], + { + cwd: GB_DIR, + stdio: 'inherit', + } +); + +process.exit( result.status === null ? 1 : result.status ); diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 254c12a7e8ded..5c63bd803f01d 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -193,6 +193,9 @@ module.exports = function( env = { environment: 'production', watch: false, buil alias: { 'lodash-es': 'lodash', }, + // The @wordpress packages are symlinked from gb-src. Resolve them via + // node_modules so module paths, and thus hashed module IDs, are stable. + symlinks: false, }, module: { rules: [